Compare commits

12 Commits
Author SHA1 Message Date
evandro c8d7d61e9a docs: update README for tests, CI, scripts and the current project structure 2026-10-10 04:34:33 -03:00
evandro 541aefd7e4 feat: check the server on launch and remember the selected model
The chat showed 'Server offline' until the user opened Settings, and the selected model was forgotten on every restart. useAutoConnect fetches the models once after the persisted settings are restored; activeModelId is now persisted.
2026-10-10 04:31:21 -03:00
evandro 1a3ce9ccec fix(chat): do not delete the answer when regenerating without a selected model
regenerateResponse removed the previous answer before checking for a model and then returned silently, leaving the conversation without a reply. It now checks first and explains that a model is required, like sendMessage. The duplicated streaming code in sendMessage and regenerateResponse is now a single streamAnswer helper.
2026-10-10 04:31:21 -03:00
evandro b441339bb4 feat(a11y): label icon-only buttons and inputs for screen readers
Header, message action, send/stop, history, model selector and dark-mode controls had no accessible name (icon-only buttons). Adds roles/labels and selected/disabled state; no visual change.
2026-10-10 04:29:33 -03:00
evandro 0e5b38fd15 perf(chat): debounce persisted writes and re-render only the streaming message
- the Zustand persist middleware serialized every saved session and wrote to AsyncStorage on each streamed token (appendToMessage -> set); writes are now debounced (400 ms) and flushed when the app leaves the foreground
- every token re-rendered every MessageBubble, re-parsing the Markdown of the whole conversation; rows are memoized so only the changed message renders (covered by MessageList.test.tsx)
2026-10-10 04:28:38 -03:00
evandro 200fb6afd7 chore: remove unused Expo template files and dependencies, add CI workflow
- 17 files unreferenced by the app (knip): hello-wave, parallax-scroll-view, themed-*, icon-symbol, Button, GlassCard, ScreenWrapper, Models catalog, duplicated theme/color-scheme hooks
- scripts/reset-project.js and its npm script: it moves app/ to app-example and would wipe the project
- dependencies only used by those files: expo-image, expo-symbols, expo-web-browser, react-native-sse, @babel/plugin-transform-runtime
- GitHub Actions workflow running typecheck, lint, tests and the web export
2026-10-10 04:26:21 -03:00
evandro 7ee78af76d chore(deps): align packages with Expo SDK 54 (react 19.1.0, expo-clipboard 8.x, patch bumps)
- react was ^19.1.0 and resolved to 19.3.0 while react-dom and the React Native renderer are 19.1.0; pin 19.1.0
- expo-clipboard ^55 is the SDK 55 release; SDK 54 expects ~8.0.8 (npx expo install --check)
- patch bumps for expo, expo-router, expo-constants, expo-font, expo-linking, expo-web-browser
- babel-preset-expo is required by babel.config.js, so declare it
2026-10-10 04:24:40 -03:00
evandro 2a294f1f87 refactor(chat): split MessageBubble into avatar, content and code block components
MessageBubble CC 19 -> below 10. The fence and indented code block rules were identical and are now one CodeBlock component; the Markdown style map lives in markdownStyles.ts. Adds characterization tests for the bubble (roles, actions visibility, edited badge, copy feedback).
2026-10-10 04:20:59 -03:00
evandro 434cba2a69 refactor(settings): split SettingsScreen into section components and pure helpers
SettingsScreen CC 23 -> below 10. Form parsing, change detection and the dark-theme check moved to utils/ with unit tests; the dark switch now reads the OS scheme instead of comparing the background colour. Adds characterization tests for the screen.
2026-10-10 04:18:40 -03:00
evandro cb6eda0562 refactor(chat): split ChatScreen and reuse regenerateResponse after editing a message
The edit path duplicated the streaming flow with inline require() calls; it now reuses regenerateResponse, which also fixes:
- edited conversations were never saved to History
- the answer generated after an edit could not be stopped (no abort handle kept)
Stopping a stream now saves the partial conversation, like finishing or failing does.
Adds characterization tests for ChatScreen and useLMStudio.
2026-10-10 04:16:29 -03:00
evandro 808cb1610b fix(api): handle hosts starting with http, HTTP errors on XHR and clearer connection errors
- buildBaseUrl treated any host starting with 'http' (e.g. httpbin.local) as having a scheme
- native streaming finished silently on 4xx/5xx; it now reports the status
- network failures now explain how to fix the connection instead of 'XHR streaming failed: 0'
- testConnection left its 5 s timer running on failure
- SSE parser accepts 'data:' without a space, as the spec allows
2026-10-10 04:12:13 -03:00
evandro 37dfa265f5 test: set up jest-expo and cover the Zustand store 2026-10-10 04:12:13 -03:00
62 changed files with 6351 additions and 2591 deletions

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm run lint
- run: npm test
- run: npm run build
+49 -36
View File
@@ -1,5 +1,7 @@
# LocalMind
[![CI](https://github.com/evandrodevbr/LocalMind/actions/workflows/ci.yml/badge.svg)](https://github.com/evandrodevbr/LocalMind/actions/workflows/ci.yml)
**Chat client for local LLMs served by LM Studio. React Native + Expo app (Android, iOS and web) where every message stays on your own network: no cloud, no API key, no telemetry.**
![React Native](https://img.shields.io/badge/React_Native-0.81.5-20232A?logo=react&logoColor=61DAFB)
@@ -32,7 +34,8 @@ Expo app (Android / iOS / web)
- The connection settings (host, port, optional API key) live in the Zustand store and are persisted with `AsyncStorage`.
- `services/lmStudioApi.ts` builds the base URL as `http(s)://<host>:<port>/v1` and streams the response: `XMLHttpRequest` progress events on native platforms, `fetch` + `ReadableStream` on web.
- Each request carries a fixed system prompt (`constants/SystemPrompt.ts`); `temperature` is `0.7` and `max_tokens` is `2048`.
- Every finished exchange is saved as a chat session (title taken from the first user message), so History can reload it later.
- Every finished (or stopped) exchange is saved as a chat session (title taken from the first user message), so History can reload it later. Writes to `AsyncStorage` are debounced (400 ms) so streaming does not serialize the whole history on every token.
- When the chat opens, the app checks the saved server once and loads its model list; the selected model is remembered between launches.
## Stack
@@ -45,13 +48,13 @@ Expo app (Android / iOS / web)
| Icons | lucide-react-native |
| Markdown | `@ronradtke/react-native-markdown-display` |
| Streaming | XMLHttpRequest (native) / fetch ReadableStream (web) |
| Tooling | TypeScript 5.9 (`strict`), ESLint 9 flat config (`eslint-config-expo`) |
| Tooling | TypeScript 5.9 (`strict`), ESLint 9 flat config (`eslint-config-expo`), Jest 29 with `jest-expo` and Testing Library |
| Model server | LM Studio local server, OpenAI-compatible `/v1` API |
## Requirements
- Node.js `>= 20.19.4` (required by React Native 0.81.5; verified here on Node 24.20.0)
- npm (or pnpm) to install dependencies
- Node.js `>= 20.19.4` (required by React Native 0.81.5; CI uses Node 22, local runs were done on Node 26.7.0)
- npm to install dependencies (the committed `package-lock.json` is the maintained lockfile)
- [LM Studio](https://lmstudio.ai) with a model downloaded, the local server started and **CORS enabled** (Local Server tab, default port `1234`)
- To run the app: [Expo Go](https://expo.dev/go) on a physical device, an Android/iOS emulator, or a browser for the web target
@@ -95,7 +98,7 @@ The app is a client of the LM Studio OpenAI-compatible API. Endpoints actually u
| `GET /v1/models` | `testConnection()`, `getModels()` | The connection test aborts after 5 s; the model list feeds the header selector. |
| `POST /v1/chat/completions` | `chatCompletion()`, `chatCompletionStream()` | Streaming (`stream: true`) is the path used by the chat; SSE lines with `choices[0].delta.content` are appended to the last message. |
- Base URL: `http://<host>:<port>/v1` (`http://` is prepended when the host has no scheme).
- Base URL: `http://<host>:<port>/v1` (`http://` is prepended when the host has no `http://` or `https://` scheme).
- An `Authorization: Bearer <apiKey>` header is sent only when an API key is filled in.
- `GET /v1/models` responds with the OpenAI shape `{ "data": [ { "id": ... } ] }`.
@@ -118,52 +121,62 @@ app/
├── index.tsx redirects to /chat
├── +not-found.tsx 404 screen
├── chat/
│ ├── _layout.tsx chat header (model selector, history, new chat, settings)
│ ├── index.tsx conversation screen, prompt suggestions, edit/regenerate
│ ├── _layout.tsx chat header (model selector, history, new chat, settings), auto-connect
│ ├── index.tsx conversation screen, edit/regenerate flow
│ └── history.tsx saved sessions
└── settings/index.tsx server connection, appearance, about
└── settings/index.tsx composes the settings cards
components/
├── chat/ ChatInput, MessageBubble, MessageActions, ModelSelector, TypingIndicator
└── ui/ Button, GlassCard, ConnectionBadge, ScreenWrapper, icons
constants/ Colors, theme, SystemPrompt (system prompt sent to the model)
hooks/ useLMStudio (chat flow), useThemeColor, useColorScheme
├── chat/ ChatInput, MessageList, MessageBubble, MessageContent, CodeBlock,
│ MessageActions, ModelSelector, EmptyState, OfflineBanner, TypingIndicator
├── settings/ ServerConnectionCard, AppearanceCard, AboutCard, LabeledInput, SettingsSection
└── ui/ ConnectionBadge
constants/ Colors, SystemPrompt (system prompt sent to the model)
hooks/ useLMStudio (chat flow), useAutoConnect, useThemeColor, useColorScheme
services/ lmStudioApi (HTTP/SSE), types
store/ useAppStore (Zustand + AsyncStorage)
store/ useAppStore (Zustand), debouncedStorage (AsyncStorage wrapper)
utils/ pure helpers (server form parsing, theme, chat selectors)
__tests__/ Jest tests
docs/ LM Studio API notes and design reference
.agent/ AI development tooling (agents, skills, rules), not app code
```
## Scripts
| Command | What it does |
|---|---|
| `npm start` | Expo dev server (`npm run web`, `npm run android`, `npm run ios` pick a target) |
| `npm run typecheck` | `tsc --noEmit` |
| `npm run lint` | `expo lint` |
| `npm test` | Jest (`jest-expo`), tests in `__tests__/` |
| `npm run build` | `expo export --platform web`, writes `dist/` |
The GitHub Actions workflow (`.github/workflows/ci.yml`) runs `typecheck`, `lint`, `test` and `build` on pushes to `main` and on pull requests.
## Tests
`npm test` runs 11 suites (120 tests) covering the pure and stateful logic and the main screens:
- `lmStudioApi`: base URL building, SSE parsing, connection test and timeout, model list, web (`fetch`) and native (`XMLHttpRequest`) streaming, HTTP and connection errors;
- `useAppStore`, `debouncedStorage`: messages, chat sessions, persistence, write debouncing;
- `useLMStudio`, `useAutoConnect`: sending, regenerating, stopping, model loading;
- `ChatScreen`, `SettingsScreen`, `MessageBubble`, `MessageList`, `ChatInput` (rendered with Testing Library) and `utils`.
The native streaming path is exercised against a fake `XMLHttpRequest`, not a real device.
## Verification
There is no automated test suite in this repository. What exists and was actually run on Linux (Node 24.20.0, npm 11.19.0):
Last run on Windows 11 (Node 26.7.0, npm): `npm ci`, `npm run typecheck`, `npm run lint` (0 errors, 0 warnings), `npm test` (120 passed) and `npm run build` (6 static routes in `dist/`) all succeed.
| Command | Result |
|---|---|
| `npm install` | exit 0 |
| `npx tsc --noEmit` | exit 0, no type errors |
| `npx expo lint` | exit 0, 0 errors and 3 warnings (`no-require-imports` twice and one `exhaustive-deps` in `app/chat/index.tsx`) |
| `npx expo export --platform web` | exit 0, 6 static routes in `dist/` |
| `npx expo start --web` | dev server serves the app on `http://localhost:8083`, no browser errors |
End-to-end run of the exported web build in headless Chromium, against a local mock of the LM Studio `/v1` API:
- `GET /v1/models` and `POST /v1/chat/completions` (SSE) reached the app: Test Connection returned *Connection successful*, and the header listed the mock model;
- a message sent from the chat was answered by the mock and rendered incrementally: `Ola! Sou um modelo local rodando via mock do LM Studio. Teste E2E concluido com sucesso.`;
- the session was persisted (`localStorage` key `localaiapp-storage`) and showed up in History with `2 msgs`;
- zero uncaught page errors after the `import.meta` fix described below.
Not verified in this environment: Android and iOS builds (no emulator or device available), and therefore the native streaming path via `XMLHttpRequest`.
Not verified: Android and iOS builds (no emulator or device available) and a run against a real LM Studio server. An earlier manual run of the exported web build against a mock of the `/v1` API (headless Chromium) answered a chat message and persisted the session in `localStorage` under `localaiapp-storage`.
## Current state and limitations
- Alpha. It was built against models served by LM Studio and has no fallback to any cloud API.
- No auto-connect on launch: the app only checks the server from Settings or when the model selector is refreshed. Until then the chat shows *Server offline*.
- `activeModelId` and `availableModels` are not persisted, so after restarting the app you have to select a model again.
- `constants/Models.ts` (a Qwen model catalog) is not referenced anywhere in the app.
- `react-native-sse` is declared in `package.json` but no code imports it.
- Both `package-lock.json` and `pnpm-lock.yaml` are committed.
- The server is checked once when the chat opens and whenever the model selector is refreshed or Settings tests the connection; there is no periodic reconnect.
- The stored model id is not validated against the server's model list after a restart.
- Both `package-lock.json` and `pnpm-lock.yaml` are committed; only `package-lock.json` is kept in sync (CI uses `npm ci`).
- `app.json` still carries the previous project name (`LocalAiApp` / slug `localaiapp`) and the persisted storage key is `localaiapp-storage`.
- No CI, no tests, no license file.
- No license file.
## Documentation
+39
View File
@@ -0,0 +1,39 @@
import { fireEvent, render, screen } from '@testing-library/react-native';
import React from 'react';
import { ChatInput } from '../components/chat/ChatInput';
describe('ChatInput', () => {
it('sends trimmed text, clears the field and disables send while empty', () => {
const onSend = jest.fn();
render(<ChatInput onSend={onSend} />);
expect(screen.getByLabelText('Send message').props.accessibilityState.disabled).toBe(true);
fireEvent.changeText(screen.getByLabelText('Message'), ' hello ');
fireEvent.press(screen.getByLabelText('Send message'));
expect(onSend).toHaveBeenCalledWith('hello');
expect(screen.getByLabelText('Message').props.value).toBe('');
});
it('shows a stop button instead of send while loading', () => {
const onStop = jest.fn();
render(<ChatInput onSend={jest.fn()} onStop={onStop} isLoading />);
expect(screen.queryByLabelText('Send message')).toBeNull();
fireEvent.press(screen.getByLabelText('Stop generating'));
expect(onStop).toHaveBeenCalledTimes(1);
});
it('fills the field when editing and lets the user cancel', () => {
const onCancelEdit = jest.fn();
render(<ChatInput onSend={jest.fn()} editingText="old text" onCancelEdit={onCancelEdit} />);
expect(screen.getByText('Editing message')).toBeTruthy();
expect(screen.getByLabelText('Message').props.value).toBe('old text');
fireEvent.press(screen.getByLabelText('Cancel editing'));
expect(onCancelEdit).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText('Message').props.value).toBe('');
});
});
+221
View File
@@ -0,0 +1,221 @@
import { act, fireEvent, render, screen } from '@testing-library/react-native';
import React from 'react';
import ChatScreen from '../app/chat/index';
import * as api from '../services/lmStudioApi';
import { useAppStore } from '../store/useAppStore';
jest.mock('../services/lmStudioApi');
jest.mock('react-native-reanimated', () => {
const { View } = require('react-native');
const chain: any = new Proxy(() => chain, { get: () => chain });
return { __esModule: true, default: { View }, FadeInDown: chain };
});
jest.mock('../components/chat/MessageBubble', () => {
const { Text } = require('react-native');
return {
MessageBubble: ({ message, isLastAssistant }: any) => (
<Text testID={`bubble-${message.id}`}>{`${message.role}:${message.content}${isLastAssistant ? ':last' : ''}`}</Text>
),
};
});
jest.mock('../components/chat/TypingIndicator', () => {
const { Text } = require('react-native');
return { TypingIndicator: () => <Text>typing-indicator</Text> };
});
let inputProps: any;
jest.mock('../components/chat/ChatInput', () => ({
ChatInput: (props: any) => {
inputProps = props;
return null;
},
}));
const streamMock = api.chatCompletionStream as jest.Mock;
const state = () => useAppStore.getState();
beforeEach(() => {
jest.useFakeTimers();
streamMock.mockReset();
useAppStore.setState(useAppStore.getInitialState(), true);
useAppStore.setState({ isConnected: true, activeModelId: 'model-a' });
});
afterEach(() => {
jest.useRealTimers();
});
describe('ChatScreen', () => {
it('shows the empty state with prompt suggestions when there are no messages', () => {
render(<ChatScreen />);
expect(screen.getByText('Start a conversation')).toBeTruthy();
expect(screen.getByText('Explain recursion in simple terms')).toBeTruthy();
});
it('sends a suggestion as a user message and starts streaming', () => {
render(<ChatScreen />);
fireEvent.press(screen.getByText('Explain recursion in simple terms'));
expect(state().messages.map((m) => `${m.role}:${m.content}`)).toEqual([
'user:Explain recursion in simple terms',
'assistant:',
]);
expect(streamMock).toHaveBeenCalledTimes(1);
expect(streamMock.mock.calls[0][1]).toBe('model-a');
});
it('hides the empty state and lists the messages once there are some', () => {
state().addMessage({ role: 'user', content: 'hello' });
render(<ChatScreen />);
expect(screen.queryByText('Start a conversation')).toBeNull();
expect(screen.getByText('user:hello')).toBeTruthy();
});
it('shows the offline banner only when the server is not connected', () => {
const { rerender } = render(<ChatScreen />);
expect(screen.queryByText(/Server offline/)).toBeNull();
act(() => {
state().setIsConnected(false);
});
rerender(<ChatScreen />);
expect(screen.getByText(/Server offline/)).toBeTruthy();
});
it('shows the typing indicator only while an empty assistant message is streaming', () => {
state().addMessage({ role: 'user', content: 'q' });
const assistantId = state().addMessage({ role: 'assistant', content: '' });
act(() => {
state().setIsStreaming(true);
});
render(<ChatScreen />);
expect(screen.getByText('typing-indicator')).toBeTruthy();
act(() => {
state().appendToMessage(assistantId, 'partial');
});
expect(screen.queryByText('typing-indicator')).toBeNull();
});
it('does not show the typing indicator when not streaming', () => {
state().addMessage({ role: 'assistant', content: '' });
render(<ChatScreen />);
expect(screen.queryByText('typing-indicator')).toBeNull();
});
it('marks only the last assistant message as regenerable', () => {
state().addMessage({ role: 'user', content: 'q1' });
state().addMessage({ role: 'assistant', content: 'a1' });
state().addMessage({ role: 'user', content: 'q2' });
state().addMessage({ role: 'assistant', content: 'a2' });
render(<ChatScreen />);
expect(screen.getByText('assistant:a1')).toBeTruthy();
expect(screen.getByText('assistant:a2:last')).toBeTruthy();
});
it('sends typed text through the normal path', () => {
render(<ChatScreen />);
act(() => {
inputProps.onSend('typed');
});
expect(state().messages[0]).toMatchObject({ role: 'user', content: 'typed' });
expect(streamMock).toHaveBeenCalledTimes(1);
});
describe('editing a message', () => {
const setup = () => {
state().addMessage({ role: 'user', content: 'old question' });
state().addMessage({ role: 'assistant', content: 'old answer' });
const id = state().messages[0].id;
render(<ChatScreen />);
act(() => {
state().setEditingMessageId(id);
});
return id;
};
it('passes the edited message text to the input and clears edit mode on cancel', () => {
setup();
expect(inputProps.editingText).toBe('old question');
act(() => {
inputProps.onCancelEdit();
});
expect(state().editingMessageId).toBeNull();
});
it('rewrites the message, drops the answers after it and regenerates', () => {
const id = setup();
act(() => {
inputProps.onSend('new question');
});
act(() => {
jest.advanceTimersByTime(100);
});
expect(state().editingMessageId).toBeNull();
expect(state().messages[0]).toMatchObject({ id, content: 'new question', isEdited: true });
expect(state().messages.map((m) => `${m.role}:${m.content}`)).toEqual([
'user:new question',
'assistant:',
]);
expect(streamMock).toHaveBeenCalledTimes(1);
const [, modelId, apiMessages] = streamMock.mock.calls[0];
expect(modelId).toBe('model-a');
expect(apiMessages.slice(1)).toEqual([{ role: 'user', content: 'new question' }]);
expect(apiMessages[0].role).toBe('system');
});
it('edits and explains that a model is needed when none is selected', () => {
const id = setup();
act(() => {
useAppStore.setState({ activeModelId: '' });
});
act(() => {
inputProps.onSend('new question');
});
act(() => {
jest.advanceTimersByTime(100);
});
expect(state().messages).toHaveLength(2);
expect(state().messages[0]).toMatchObject({ id, content: 'new question' });
expect(state().messages[1].content).toContain('No model selected');
expect(streamMock).not.toHaveBeenCalled();
});
it('lets the user stop an answer generated after an edit', () => {
const abort = jest.fn();
streamMock.mockReturnValue({ abort });
setup();
act(() => {
inputProps.onSend('new question');
});
act(() => {
jest.advanceTimersByTime(100);
});
act(() => {
inputProps.onStop();
});
expect(abort).toHaveBeenCalledTimes(1);
expect(state().isStreaming).toBe(false);
});
it('saves the edited conversation when the regenerated answer finishes', () => {
streamMock.mockImplementation((_c, _m, _msgs, onChunk, onDone) => {
onChunk('fresh answer');
onDone();
return { abort: jest.fn() };
});
setup();
act(() => {
inputProps.onSend('new question');
});
act(() => {
jest.advanceTimersByTime(100);
});
expect(state().chatSessions).toHaveLength(1);
expect(state().chatSessions[0].messages.map((m) => m.content)).toEqual(['new question', 'fresh answer']);
});
});
});
+154
View File
@@ -0,0 +1,154 @@
import { act, fireEvent, render, screen } from '@testing-library/react-native';
import * as Clipboard from 'expo-clipboard';
import React from 'react';
import { MessageBubble } from '../components/chat/MessageBubble';
import { ChatMessage } from '../store/useAppStore';
jest.mock('expo-clipboard', () => ({ setStringAsync: jest.fn(() => Promise.resolve(true)) }));
let actionsProps: any;
jest.mock('../components/chat/MessageActions', () => ({
MessageActions: (props: any) => {
actionsProps = props;
const { Text } = require('react-native');
return <Text>actions</Text>;
},
}));
const message = (overrides: Partial<ChatMessage> = {}): ChatMessage => ({
id: '1',
role: 'assistant',
content: 'hello **world**',
timestamp: new Date(2026, 0, 1, 13, 5).getTime(),
...overrides,
});
beforeEach(() => {
actionsProps = undefined;
(Clipboard.setStringAsync as jest.Mock).mockClear();
});
describe('MessageBubble user message', () => {
it('shows the plain text and keeps the actions hidden until touched', () => {
render(<MessageBubble message={message({ role: 'user', content: 'hi there' })} />);
expect(screen.getByText('hi there')).toBeTruthy();
expect(screen.queryByText('actions')).toBeNull();
});
it('reveals edit and delete (but not regenerate) after a touch', () => {
const onEdit = jest.fn();
const onDelete = jest.fn();
const onRegenerate = jest.fn();
render(
<MessageBubble
message={message({ role: 'user', content: 'hi' })}
onEdit={onEdit}
onDelete={onDelete}
onRegenerate={onRegenerate}
isLastAssistant
/>,
);
fireEvent(screen.getByText('hi').parent!.parent!.parent!.parent!, 'touchStart');
expect(screen.getByText('actions')).toBeTruthy();
expect(actionsProps.role).toBe('user');
expect(actionsProps.onEdit).toBe(onEdit);
expect(actionsProps.onDelete).toBe(onDelete);
expect(actionsProps.onRegenerate).toBeUndefined();
});
it('never renders actions for an empty message', () => {
render(<MessageBubble message={message({ role: 'user', content: '' })} />);
fireEvent(screen.getByText(/\d/).parent!.parent!.parent!, 'touchStart');
expect(screen.queryByText('actions')).toBeNull();
});
});
describe('MessageBubble assistant message', () => {
it('renders markdown and always shows actions', () => {
render(<MessageBubble message={message()} />);
expect(screen.getByText('world')).toBeTruthy();
expect(screen.getByText('actions')).toBeTruthy();
});
it('offers regenerate only on the last assistant message and never edit', () => {
const onEdit = jest.fn();
const onRegenerate = jest.fn();
const { rerender } = render(<MessageBubble message={message()} onEdit={onEdit} onRegenerate={onRegenerate} />);
expect(actionsProps.onRegenerate).toBeUndefined();
expect(actionsProps.onEdit).toBeUndefined();
rerender(<MessageBubble message={message()} onEdit={onEdit} onRegenerate={onRegenerate} isLastAssistant />);
expect(actionsProps.onRegenerate).toBe(onRegenerate);
expect(actionsProps.onEdit).toBeUndefined();
});
it('flags actions as streaming only for the last assistant message', () => {
const { rerender } = render(<MessageBubble message={message()} isStreaming />);
expect(actionsProps.isStreaming).toBeFalsy();
rerender(<MessageBubble message={message()} isStreaming isLastAssistant />);
expect(actionsProps.isStreaming).toBe(true);
});
it('renders nothing inside the bubble or actions while an empty answer is streaming', () => {
render(<MessageBubble message={message({ content: '' })} isStreaming isLastAssistant />);
expect(screen.queryByText('actions')).toBeNull();
});
it('renders an empty answer as markdown when not streaming', () => {
render(<MessageBubble message={message({ content: '' })} />);
expect(screen.queryByText('actions')).toBeNull();
});
});
describe('MessageBubble metadata', () => {
it('shows the time and the edited badge only for edited messages', () => {
const { rerender } = render(<MessageBubble message={message({ role: 'user' })} />);
expect(screen.queryByText(/edited/)).toBeNull();
rerender(<MessageBubble message={message({ role: 'user', isEdited: true })} />);
expect(screen.getByText(/· edited/)).toBeTruthy();
});
});
describe('MessageBubble code blocks', () => {
const fenced = 'text\n\n```ts\nconst a = 1;\n```\n';
it('shows the language, the code and copies it to the clipboard', async () => {
jest.useFakeTimers();
try {
render(<MessageBubble message={message({ content: fenced })} />);
expect(screen.getByText('ts')).toBeTruthy();
expect(screen.getByText(/const a = 1;/)).toBeTruthy();
expect(screen.getByText('Copy')).toBeTruthy();
await act(async () => {
fireEvent.press(screen.getByText('Copy'));
});
expect(Clipboard.setStringAsync).toHaveBeenCalledWith('const a = 1;\n');
expect(screen.getByText('Copied!')).toBeTruthy();
act(() => {
jest.advanceTimersByTime(2000);
});
expect(screen.getByText('Copy')).toBeTruthy();
} finally {
jest.useRealTimers();
}
});
it('labels a fence without a language as "code"', () => {
render(<MessageBubble message={message({ content: '```\nplain\n```\n' })} />);
expect(screen.getByText('code')).toBeTruthy();
});
it('renders indented code blocks with a "code" header and copy button', async () => {
render(<MessageBubble message={message({ content: 'intro\n\n indented()\n' })} />);
expect(screen.getByText('code')).toBeTruthy();
await act(async () => {
fireEvent.press(screen.getByText('Copy'));
});
expect(Clipboard.setStringAsync).toHaveBeenCalledWith(expect.stringContaining('indented()'));
});
});
+78
View File
@@ -0,0 +1,78 @@
import { render, screen } from '@testing-library/react-native';
import React from 'react';
import { MessageList } from '../components/chat/MessageList';
import { ChatMessage } from '../store/useAppStore';
jest.mock('react-native-reanimated', () => {
const { View } = require('react-native');
const chain: any = new Proxy(() => chain, { get: () => chain });
return { __esModule: true, default: { View }, FadeInDown: chain };
});
const mockRenders: Record<string, number> = {};
jest.mock('../components/chat/MessageBubble', () => {
const { Text } = require('react-native');
return {
MessageBubble: ({ message }: any) => {
mockRenders[message.id] = (mockRenders[message.id] ?? 0) + 1;
return <Text>{`${message.id}:${message.content}`}</Text>;
},
};
});
jest.mock('../components/chat/TypingIndicator', () => {
const { Text } = require('react-native');
return { TypingIndicator: () => <Text>typing-indicator</Text> };
});
const msg = (id: string, role: ChatMessage['role'], content: string): ChatMessage => ({
id,
role,
content,
timestamp: 0,
});
const handlers = { onEdit: jest.fn(), onDelete: jest.fn(), onRegenerate: jest.fn() };
beforeEach(() => {
Object.keys(mockRenders).forEach((k) => delete mockRenders[k]);
});
describe('MessageList', () => {
it('renders every message', () => {
render(
<MessageList
messages={[msg('u1', 'user', 'q'), msg('a1', 'assistant', 'a')]}
isStreaming={false}
{...handlers}
/>,
);
expect(screen.getByText('u1:q')).toBeTruthy();
expect(screen.getByText('a1:a')).toBeTruthy();
});
it('re-renders only the message that changed while an answer streams in', () => {
const user = msg('u1', 'user', 'question');
const earlier = msg('a0', 'assistant', 'earlier answer');
const streaming = msg('a1', 'assistant', 'He');
const { rerender } = render(
<MessageList messages={[user, earlier, streaming]} isStreaming {...handlers} />,
);
expect(mockRenders).toEqual({ u1: 1, a0: 1, a1: 1 });
rerender(
<MessageList messages={[user, earlier, { ...streaming, content: 'Hello' }]} isStreaming {...handlers} />,
);
expect(screen.getByText('a1:Hello')).toBeTruthy();
expect(mockRenders.u1).toBe(1);
expect(mockRenders.a0).toBe(1);
expect(mockRenders.a1).toBe(2);
});
it('shows the typing indicator while the last assistant message is still empty', () => {
render(
<MessageList messages={[msg('u1', 'user', 'q'), msg('a1', 'assistant', '')]} isStreaming {...handlers} />,
);
expect(screen.getByText('typing-indicator')).toBeTruthy();
});
});
+172
View File
@@ -0,0 +1,172 @@
import { act, fireEvent, render, screen } from '@testing-library/react-native';
import React from 'react';
import SettingsScreen from '../app/settings/index';
import * as api from '../services/lmStudioApi';
import { DEFAULT_SERVER_CONFIG } from '../services/types';
import { useAppStore } from '../store/useAppStore';
jest.mock('../services/lmStudioApi');
let mockSystemScheme: 'light' | 'dark' = 'light';
jest.mock('../hooks/useColorScheme', () => ({
useColorScheme: () => mockSystemScheme,
}));
const mockApi = api as jest.Mocked<typeof api>;
const state = () => useAppStore.getState();
const hostInput = () => screen.getByPlaceholderText('localhost');
const portInput = () => screen.getByPlaceholderText('1234');
const keyInput = () => screen.getByPlaceholderText('Leave empty for local');
beforeEach(() => {
jest.resetAllMocks();
mockSystemScheme = 'light';
useAppStore.setState(useAppStore.getInitialState(), true);
mockApi.getModels.mockResolvedValue([]);
});
describe('SettingsScreen connection form', () => {
it('is filled from the stored configuration and hides "Save Changes" until something changes', () => {
useAppStore.setState({ serverConfig: { host: '192.168.0.5', port: 4321, apiKey: 'k' } });
render(<SettingsScreen />);
expect(hostInput().props.value).toBe('192.168.0.5');
expect(portInput().props.value).toBe('4321');
expect(keyInput().props.value).toBe('k');
expect(screen.queryByText('Save Changes')).toBeNull();
});
it('shows "Save Changes" for each changed field', () => {
render(<SettingsScreen />);
fireEvent.changeText(hostInput(), 'other');
expect(screen.getByText('Save Changes')).toBeTruthy();
fireEvent.changeText(hostInput(), DEFAULT_SERVER_CONFIG.host);
expect(screen.queryByText('Save Changes')).toBeNull();
fireEvent.changeText(portInput(), '999');
expect(screen.getByText('Save Changes')).toBeTruthy();
fireEvent.changeText(portInput(), String(DEFAULT_SERVER_CONFIG.port));
expect(screen.queryByText('Save Changes')).toBeNull();
fireEvent.changeText(keyInput(), 'secret');
expect(screen.getByText('Save Changes')).toBeTruthy();
});
it('saves trimmed values', () => {
render(<SettingsScreen />);
fireEvent.changeText(hostInput(), ' 192.168.0.9 ');
fireEvent.changeText(portInput(), '8080');
fireEvent.changeText(keyInput(), ' key ');
fireEvent.press(screen.getByText('Save Changes'));
expect(state().serverConfig).toEqual({ host: '192.168.0.9', port: 8080, apiKey: 'key' });
});
it('falls back to localhost:1234 and no API key when the fields are blank or invalid', () => {
useAppStore.setState({ serverConfig: { host: 'lan', port: 4321, apiKey: 'k' } });
render(<SettingsScreen />);
fireEvent.changeText(hostInput(), ' ');
fireEvent.changeText(portInput(), 'abc');
fireEvent.changeText(keyInput(), ' ');
fireEvent.press(screen.getByText('Save Changes'));
expect(state().serverConfig).toEqual({ host: 'localhost', port: 1234, apiKey: undefined });
});
});
describe('SettingsScreen connection test', () => {
it('saves the configuration, reports success and loads the models', async () => {
mockApi.testConnection.mockResolvedValue(true);
render(<SettingsScreen />);
fireEvent.changeText(hostInput(), '10.0.0.2');
await act(async () => {
fireEvent.press(screen.getByText('Test Connection'));
});
expect(mockApi.testConnection).toHaveBeenCalledWith({ host: '10.0.0.2', port: 1234, apiKey: undefined });
expect(state().serverConfig.host).toBe('10.0.0.2');
expect(state().isConnected).toBe(true);
expect(mockApi.getModels).toHaveBeenCalledTimes(1);
expect(screen.getByText('Connection successful. API is ready.')).toBeTruthy();
expect(screen.getByText('Connected')).toBeTruthy();
});
it('reports failure and does not load models', async () => {
mockApi.testConnection.mockResolvedValue(false);
render(<SettingsScreen />);
await act(async () => {
fireEvent.press(screen.getByText('Test Connection'));
});
expect(state().isConnected).toBe(false);
expect(mockApi.getModels).not.toHaveBeenCalled();
expect(screen.getByText('Connection failed. Verify host and port.')).toBeTruthy();
expect(screen.getByText('Disconnected')).toBeTruthy();
});
it('shows a spinner and ignores presses while the test is running', async () => {
let resolveTest: (ok: boolean) => void = () => {};
mockApi.testConnection.mockReturnValue(new Promise((resolve) => (resolveTest = resolve)));
render(<SettingsScreen />);
await act(async () => {
fireEvent.press(screen.getByText('Test Connection'));
});
expect(screen.queryByText('Test Connection')).toBeNull();
await act(async () => {
resolveTest(false);
});
expect(screen.getByText('Test Connection')).toBeTruthy();
});
it('clears a previous result when the configuration is saved again', async () => {
mockApi.testConnection.mockResolvedValue(false);
render(<SettingsScreen />);
await act(async () => {
fireEvent.press(screen.getByText('Test Connection'));
});
expect(screen.getByText(/Connection failed/)).toBeTruthy();
fireEvent.changeText(hostInput(), 'changed');
fireEvent.press(screen.getByText('Save Changes'));
expect(screen.queryByText(/Connection failed/)).toBeNull();
});
});
describe('SettingsScreen dark mode switch', () => {
const darkSwitch = () => screen.UNSAFE_getByType(require('react-native').Switch);
it('is on for the dark preference and switches to light', () => {
useAppStore.setState({ theme: 'dark' });
render(<SettingsScreen />);
expect(darkSwitch().props.value).toBe(true);
fireEvent(darkSwitch(), 'valueChange', false);
expect(state().theme).toBe('light');
});
it('is off for the light preference and switches to dark', () => {
useAppStore.setState({ theme: 'light' });
render(<SettingsScreen />);
expect(darkSwitch().props.value).toBe(false);
fireEvent(darkSwitch(), 'valueChange', true);
expect(state().theme).toBe('dark');
});
it('follows the system scheme when the preference is "system"', () => {
mockSystemScheme = 'dark';
const first = render(<SettingsScreen />);
expect(darkSwitch().props.value).toBe(true);
first.unmount();
mockSystemScheme = 'light';
render(<SettingsScreen />);
expect(darkSwitch().props.value).toBe(false);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { createDebouncedStorage } from '../store/debouncedStorage';
const makeBase = () => ({
getItem: jest.fn(async (_name: string): Promise<string | null> => 'from-disk'),
setItem: jest.fn(async (_name: string, _value: string) => {}),
removeItem: jest.fn(async (_name: string) => {}),
});
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
describe('createDebouncedStorage', () => {
it('collapses a burst of writes into one trailing write with the latest value', () => {
const base = makeBase();
const storage = createDebouncedStorage(base, 400);
storage.setItem('k', '1');
jest.advanceTimersByTime(300);
storage.setItem('k', '2');
jest.advanceTimersByTime(300);
storage.setItem('k', '3');
expect(base.setItem).not.toHaveBeenCalled();
jest.advanceTimersByTime(400);
expect(base.setItem).toHaveBeenCalledTimes(1);
expect(base.setItem).toHaveBeenCalledWith('k', '3');
});
it('reads a pending value before it reaches the disk, and falls back to the base storage', async () => {
const base = makeBase();
const storage = createDebouncedStorage(base);
await expect(storage.getItem('k')).resolves.toBe('from-disk');
storage.setItem('k', 'fresh');
await expect(storage.getItem('k')).resolves.toBe('fresh');
});
it('flush writes immediately and cancels the timer', async () => {
const base = makeBase();
const storage = createDebouncedStorage(base);
storage.setItem('a', '1');
storage.setItem('b', '2');
await storage.flush();
expect(base.setItem.mock.calls).toEqual([['a', '1'], ['b', '2']]);
jest.advanceTimersByTime(1000);
expect(base.setItem).toHaveBeenCalledTimes(2);
});
it('flush with nothing pending does not write', async () => {
const base = makeBase();
await createDebouncedStorage(base).flush();
expect(base.setItem).not.toHaveBeenCalled();
});
it('removeItem drops a pending write and removes from the base storage', async () => {
const base = makeBase();
const storage = createDebouncedStorage(base);
storage.setItem('k', '1');
await storage.removeItem('k');
jest.advanceTimersByTime(1000);
expect(base.setItem).not.toHaveBeenCalled();
expect(base.removeItem).toHaveBeenCalledWith('k');
});
});
+390
View File
@@ -0,0 +1,390 @@
import { Platform } from 'react-native';
import {
buildBaseUrl,
chatCompletion,
chatCompletionStream,
getModels,
parseSSELines,
testConnection,
} from '../services/lmStudioApi';
import { ServerConfig } from '../services/types';
const config: ServerConfig = { host: '192.168.0.10', port: 1234 };
const fetchMock = jest.fn();
const originalFetch = global.fetch;
beforeEach(() => {
fetchMock.mockReset();
global.fetch = fetchMock as unknown as typeof fetch;
});
afterAll(() => {
global.fetch = originalFetch;
});
const sseLine = (content: string) => `data: ${JSON.stringify({ choices: [{ delta: { content } }] })}`;
describe('buildBaseUrl', () => {
it('prepends http:// and appends port and /v1', () => {
expect(buildBaseUrl(config)).toBe('http://192.168.0.10:1234/v1');
});
it('keeps an explicit http or https scheme', () => {
expect(buildBaseUrl({ host: 'http://lan.local', port: 8080 })).toBe('http://lan.local:8080/v1');
expect(buildBaseUrl({ host: 'https://lan.local', port: 443 })).toBe('https://lan.local:443/v1');
});
it('strips trailing slashes from the host', () => {
expect(buildBaseUrl({ host: 'http://lan.local//', port: 1234 })).toBe('http://lan.local:1234/v1');
});
it('does not mistake a host that merely starts with "http" for a scheme', () => {
expect(buildBaseUrl({ host: 'httpbin.local', port: 1234 })).toBe('http://httpbin.local:1234/v1');
});
});
describe('parseSSELines', () => {
it('emits content of every complete data line and keeps the partial tail', () => {
const onChunk = jest.fn();
const onDone = jest.fn();
const result = parseSSELines(`${sseLine('Hel')}\n${sseLine('lo')}\ndata: {"cho`, onChunk, onDone);
expect(onChunk.mock.calls).toEqual([['Hel'], ['lo']]);
expect(result).toEqual({ remaining: 'data: {"cho', isDone: false });
expect(onDone).not.toHaveBeenCalled();
});
it('stops at [DONE] and calls onDone', () => {
const onChunk = jest.fn();
const onDone = jest.fn();
const result = parseSSELines(`${sseLine('a')}\ndata: [DONE]\n${sseLine('ignored')}\n`, onChunk, onDone);
expect(onChunk.mock.calls).toEqual([['a']]);
expect(onDone).toHaveBeenCalledTimes(1);
expect(result).toEqual({ remaining: '', isDone: true });
});
it('skips malformed JSON, empty deltas, comments and non-data lines', () => {
const onChunk = jest.fn();
parseSSELines(
[
'data: {not json}',
'data: {"choices":[{"delta":{}}]}',
'data: {"choices":[]}',
': keep-alive',
'event: message',
'',
sseLine('ok'),
'',
].join('\n'),
onChunk,
jest.fn(),
);
expect(onChunk.mock.calls).toEqual([['ok']]);
});
it('handles CRLF line endings and "data:" without a space', () => {
const onChunk = jest.fn();
const noSpace = `data:${JSON.stringify({ choices: [{ delta: { content: 'nospace' } }] })}`;
parseSSELines(`${sseLine('crlf')}\r\n${noSpace}\n`, onChunk, jest.fn());
expect(onChunk.mock.calls).toEqual([['crlf'], ['nospace']]);
});
});
describe('testConnection', () => {
it('sends the Authorization header only when an API key is set', async () => {
fetchMock.mockResolvedValue({ ok: true });
await testConnection({ ...config, apiKey: 'secret' });
await testConnection({ ...config, apiKey: '' });
expect(fetchMock.mock.calls[0][0]).toBe('http://192.168.0.10:1234/v1/models');
expect(fetchMock.mock.calls[0][1].headers).toMatchObject({ Authorization: 'Bearer secret' });
expect(fetchMock.mock.calls[1][1].headers).not.toHaveProperty('Authorization');
});
it('returns true on a 2xx response and false otherwise', async () => {
fetchMock.mockResolvedValueOnce({ ok: true });
await expect(testConnection(config)).resolves.toBe(true);
fetchMock.mockResolvedValueOnce({ ok: false });
await expect(testConnection(config)).resolves.toBe(false);
});
it('returns false when the network call throws', async () => {
fetchMock.mockRejectedValue(new TypeError('Network request failed'));
await expect(testConnection(config)).resolves.toBe(false);
});
it('aborts after 5 seconds and returns false', async () => {
jest.useFakeTimers();
try {
fetchMock.mockImplementation(
(_url: string, init: { signal: AbortSignal }) =>
new Promise((_resolve, reject) => {
init.signal.addEventListener('abort', () => reject(new Error('aborted')));
}),
);
const pending = testConnection(config);
await jest.advanceTimersByTimeAsync(5000);
await expect(pending).resolves.toBe(false);
} finally {
jest.useRealTimers();
}
});
it('does not leave the timeout timer running after a failure', async () => {
jest.useFakeTimers();
try {
fetchMock.mockRejectedValue(new TypeError('Network request failed'));
await testConnection(config);
expect(jest.getTimerCount()).toBe(0);
} finally {
jest.useRealTimers();
}
});
});
describe('getModels', () => {
it('returns the data array', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ data: [{ id: 'm1' }] }) });
await expect(getModels(config)).resolves.toEqual([{ id: 'm1' }]);
});
it('falls back to an empty list when data is missing', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({}) });
await expect(getModels(config)).resolves.toEqual([]);
});
it('throws with the HTTP status when the response is not ok', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 401, statusText: 'Unauthorized' });
await expect(getModels(config)).rejects.toThrow('Failed to fetch models: 401 Unauthorized');
});
});
describe('chatCompletion', () => {
it('posts a non-streaming request with defaults that options can override', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ id: 'x' }) });
await chatCompletion(config, 'model-a', [{ role: 'user', content: 'hi' }], { temperature: 0.1 });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('http://192.168.0.10:1234/v1/chat/completions');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({
model: 'model-a',
messages: [{ role: 'user', content: 'hi' }],
stream: false,
temperature: 0.1,
max_tokens: 2048,
});
});
it('throws with the HTTP status when the response is not ok', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 500, statusText: 'Server Error' });
await expect(chatCompletion(config, 'm', [])).rejects.toThrow('Chat completion failed: 500 Server Error');
});
});
describe('chatCompletionStream (web, fetch + ReadableStream)', () => {
const originalOS = Platform.OS;
const encoder = new TextEncoder();
beforeEach(() => {
Platform.OS = 'web';
});
afterEach(() => {
Platform.OS = originalOS;
});
const streamOf = (parts: string[]) =>
new ReadableStream<Uint8Array>({
start(controller) {
parts.forEach((p) => controller.enqueue(encoder.encode(p)));
controller.close();
},
});
// Starts a stream and resolves once either onDone or onError fired.
const run = () => {
const onChunk = jest.fn();
const onDone = jest.fn();
const onError = jest.fn();
const finished = new Promise<void>((resolve) => {
chatCompletionStream(
config,
'm',
[],
onChunk,
() => {
onDone();
resolve();
},
(e) => {
onError(e);
resolve();
},
);
});
return { onChunk, onDone, onError, finished };
};
it('streams chunks split across reads and calls onDone exactly once', async () => {
const whole = `${sseLine('Hi')}\n${sseLine(' there')}\ndata: [DONE]\n`;
fetchMock.mockResolvedValue({ ok: true, body: streamOf([whole.slice(0, 20), whole.slice(20)]) });
const { onChunk, onDone, onError, finished } = run();
await finished;
expect(onChunk.mock.calls).toEqual([['Hi'], [' there']]);
expect(onDone).toHaveBeenCalledTimes(1);
expect(onError).not.toHaveBeenCalled();
expect(JSON.parse(fetchMock.mock.calls[0][1].body).stream).toBe(true);
});
it('calls onDone when the stream ends without [DONE]', async () => {
fetchMock.mockResolvedValue({ ok: true, body: streamOf([`${sseLine('x')}\n`]) });
const { onChunk, onDone, finished } = run();
await finished;
expect(onChunk).toHaveBeenCalledWith('x');
expect(onDone).toHaveBeenCalledTimes(1);
});
it('reports an HTTP error through onError', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found' });
const { onError, finished } = run();
await finished;
expect(onError.mock.calls[0][0].message).toBe('Stream failed: 404 Not Found');
});
it('turns a network failure into an actionable connection error', async () => {
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
const { onError, finished } = run();
await finished;
expect(onError.mock.calls[0][0].message).toContain('Could not reach the server at http://192.168.0.10:1234/v1');
});
it('does not report an error when the request is aborted', async () => {
fetchMock.mockImplementation(
(_url: string, init: { signal: AbortSignal }) =>
new Promise((_resolve, reject) => {
init.signal.addEventListener('abort', () => {
const err = new Error('aborted');
err.name = 'AbortError';
reject(err);
});
}),
);
const onError = jest.fn();
const handle = chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), onError);
handle.abort();
await new Promise((r) => setTimeout(r, 0));
expect(onError).not.toHaveBeenCalled();
});
});
describe('chatCompletionStream (native, XMLHttpRequest)', () => {
class FakeXHR {
static last: FakeXHR;
status = 200;
statusText = 'OK';
responseText = '';
headers: Record<string, string> = {};
aborted = false;
onprogress: (() => void) | null = null;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
ontimeout: (() => void) | null = null;
open = jest.fn();
send = jest.fn();
constructor() {
FakeXHR.last = this;
}
setRequestHeader(k: string, v: string) {
this.headers[k] = v;
}
abort() {
this.aborted = true;
}
}
const originalXHR = global.XMLHttpRequest;
beforeEach(() => {
global.XMLHttpRequest = FakeXHR as unknown as typeof XMLHttpRequest;
});
afterEach(() => {
global.XMLHttpRequest = originalXHR;
});
it('parses progress events incrementally and finishes once', () => {
const onChunk = jest.fn();
const onDone = jest.fn();
chatCompletionStream({ ...config, apiKey: 'k' }, 'm', [], onChunk, onDone, jest.fn());
const xhr = FakeXHR.last;
expect(xhr.open).toHaveBeenCalledWith('POST', 'http://192.168.0.10:1234/v1/chat/completions');
expect(xhr.headers.Authorization).toBe('Bearer k');
const full = `${sseLine('A')}\n${sseLine('B')}\n`;
xhr.responseText = full.slice(0, 30);
xhr.onprogress?.();
xhr.responseText = full;
xhr.onprogress?.();
xhr.responseText = `${full}data: [DONE]\n`;
xhr.onprogress?.();
xhr.onload?.();
expect(onChunk.mock.calls).toEqual([['A'], ['B']]);
expect(onDone).toHaveBeenCalledTimes(1);
});
it('flushes a final line that has no trailing newline on load', () => {
const onChunk = jest.fn();
chatCompletionStream(config, 'm', [], onChunk, jest.fn(), jest.fn());
const xhr = FakeXHR.last;
xhr.responseText = sseLine('tail');
xhr.onprogress?.();
xhr.onload?.();
expect(onChunk.mock.calls).toEqual([['tail']]);
});
it('reports an HTTP error status instead of finishing silently', () => {
const onDone = jest.fn();
const onError = jest.fn();
chatCompletionStream(config, 'm', [], jest.fn(), onDone, onError);
const xhr = FakeXHR.last;
xhr.status = 401;
xhr.statusText = 'Unauthorized';
xhr.responseText = '{"error":"bad key"}';
xhr.onprogress?.();
xhr.onload?.();
expect(onError.mock.calls[0][0].message).toBe('Stream failed: 401 Unauthorized');
expect(onDone).not.toHaveBeenCalled();
});
it('turns a network error into an actionable connection error', () => {
const onError = jest.fn();
chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), onError);
FakeXHR.last.status = 0;
FakeXHR.last.onerror?.();
expect(onError.mock.calls[0][0].message).toContain('Could not reach the server at http://192.168.0.10:1234/v1');
});
it('reports a timeout', () => {
const onError = jest.fn();
chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), onError);
FakeXHR.last.ontimeout?.();
expect(onError.mock.calls[0][0].message).toBe('Request timed out');
});
it('abort() aborts the request and swallows errors thrown by abort', () => {
const handle = chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), jest.fn());
const xhr = FakeXHR.last;
handle.abort();
expect(xhr.aborted).toBe(true);
xhr.abort = () => {
throw new Error('boom');
};
expect(() => handle.abort()).not.toThrow();
});
});
+188
View File
@@ -0,0 +1,188 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { appStorage } from '../store/debouncedStorage';
import { useAppStore } from '../store/useAppStore';
const state = () => useAppStore.getState();
beforeEach(() => {
useAppStore.setState(useAppStore.getInitialState(), true);
});
describe('messages', () => {
it('addMessage appends with a generated id and timestamp, and returns the id', () => {
const id = state().addMessage({ role: 'user', content: 'hi' });
const [msg] = state().messages;
expect(msg.id).toBe(id);
expect(msg).toMatchObject({ role: 'user', content: 'hi' });
expect(typeof msg.timestamp).toBe('number');
});
it('generates distinct ids for messages created in the same millisecond', () => {
const ids = new Set(Array.from({ length: 50 }, () => state().addMessage({ role: 'user', content: 'x' })));
expect(ids.size).toBe(50);
});
it('appendToMessage and updateMessageContent only touch the target message', () => {
const a = state().addMessage({ role: 'assistant', content: 'He' });
const b = state().addMessage({ role: 'assistant', content: 'other' });
state().appendToMessage(a, 'llo');
expect(state().messages.map((m) => m.content)).toEqual(['Hello', 'other']);
state().updateMessageContent(b, 'replaced');
expect(state().messages.map((m) => m.content)).toEqual(['Hello', 'replaced']);
});
it('editMessage replaces the content and marks it as edited', () => {
const id = state().addMessage({ role: 'user', content: 'old' });
state().editMessage(id, 'new');
expect(state().messages[0]).toMatchObject({ content: 'new', isEdited: true });
});
it('deleteMessage removes only the given message', () => {
const a = state().addMessage({ role: 'user', content: 'a' });
state().addMessage({ role: 'assistant', content: 'b' });
state().deleteMessage(a);
expect(state().messages.map((m) => m.content)).toEqual(['b']);
});
describe('deleteMessagesAfter', () => {
it('keeps the given message and drops everything after it', () => {
const a = state().addMessage({ role: 'user', content: 'a' });
state().addMessage({ role: 'assistant', content: 'b' });
state().addMessage({ role: 'user', content: 'c' });
state().deleteMessagesAfter(a);
expect(state().messages.map((m) => m.content)).toEqual(['a']);
});
it('does nothing when the id is unknown', () => {
state().addMessage({ role: 'user', content: 'a' });
state().deleteMessagesAfter('missing');
expect(state().messages).toHaveLength(1);
});
});
it('clearChat empties the conversation and forgets the current session', () => {
state().addMessage({ role: 'user', content: 'a' });
state().setCurrentChatId('abc');
state().clearChat();
expect(state().messages).toEqual([]);
expect(state().currentChatId).toBeNull();
});
});
describe('chat sessions', () => {
it('saveCurrentChat ignores an empty conversation', () => {
state().saveCurrentChat();
expect(state().chatSessions).toEqual([]);
expect(state().currentChatId).toBeNull();
});
it('creates a session titled by the first user message and makes it current', () => {
state().addMessage({ role: 'assistant', content: 'welcome' });
state().addMessage({ role: 'user', content: 'first question' });
state().saveCurrentChat();
expect(state().chatSessions).toHaveLength(1);
const [session] = state().chatSessions;
expect(session.title).toBe('first question');
expect(session.messages).toHaveLength(2);
expect(state().currentChatId).toBe(session.id);
});
it('truncates long titles to 40 characters plus an ellipsis', () => {
state().addMessage({ role: 'user', content: 'x'.repeat(60) });
state().saveCurrentChat();
expect(state().chatSessions[0].title).toBe(`${'x'.repeat(40)}...`);
});
it('falls back to "New Chat" when there is no user message', () => {
state().addMessage({ role: 'assistant', content: 'hello' });
state().saveCurrentChat();
expect(state().chatSessions[0].title).toBe('New Chat');
});
it('updates the existing session instead of creating another one', () => {
state().addMessage({ role: 'user', content: 'q' });
state().saveCurrentChat();
const firstId = state().currentChatId;
state().addMessage({ role: 'assistant', content: 'a' });
state().saveCurrentChat();
expect(state().chatSessions).toHaveLength(1);
expect(state().chatSessions[0].id).toBe(firstId);
expect(state().chatSessions[0].messages).toHaveLength(2);
});
it('only rewrites the session that matches currentChatId', () => {
useAppStore.setState({
chatSessions: [
{ id: '1', title: 'one', updatedAt: 1, messages: [] },
{ id: '2', title: 'two', updatedAt: 2, messages: [] },
],
currentChatId: '2',
messages: [{ id: 'm', role: 'user', content: 'changed', timestamp: 3 }],
});
state().saveCurrentChat();
expect(state().chatSessions[0]).toMatchObject({ id: '1', title: 'one' });
expect(state().chatSessions[1]).toMatchObject({ id: '2', title: 'changed' });
});
it('loadChat restores the messages of a session, and ignores unknown ids', () => {
const messages = [{ id: 'm', role: 'user' as const, content: 'saved', timestamp: 1 }];
useAppStore.setState({ chatSessions: [{ id: 's', title: 't', updatedAt: 1, messages }] });
state().loadChat('missing');
expect(state().messages).toEqual([]);
state().loadChat('s');
expect(state().messages).toEqual(messages);
expect(state().currentChatId).toBe('s');
});
it('deleteChatSession resets the open conversation only when it deletes the current one', () => {
const session = (id: string) => ({ id, title: id, updatedAt: 1, messages: [] });
useAppStore.setState({
chatSessions: [session('a'), session('b')],
currentChatId: 'a',
messages: [{ id: 'm', role: 'user', content: 'x', timestamp: 1 }],
});
state().deleteChatSession('b');
expect(state().chatSessions.map((s) => s.id)).toEqual(['a']);
expect(state().messages).toHaveLength(1);
state().deleteChatSession('a');
expect(state().chatSessions).toEqual([]);
expect(state().messages).toEqual([]);
expect(state().currentChatId).toBeNull();
});
});
describe('persistence', () => {
it('persists settings, the selected model and chats but not transient connection or streaming state', async () => {
useAppStore.setState({
theme: 'dark',
serverConfig: { host: 'lan.local', port: 4321, apiKey: 'k' },
isConnected: true,
isStreaming: true,
activeModelId: 'model',
availableModels: [{ id: 'model', object: 'model', owned_by: 'x' }],
editingMessageId: 'e',
});
state().addMessage({ role: 'user', content: 'persist me' });
await appStorage.flush();
const raw = await AsyncStorage.getItem('localaiapp-storage');
const persisted = JSON.parse(raw as string).state;
expect(Object.keys(persisted).sort()).toEqual(
['activeModelId', 'chatSessions', 'currentChatId', 'messages', 'serverConfig', 'theme'],
);
expect(persisted.serverConfig).toEqual({ host: 'lan.local', port: 4321, apiKey: 'k' });
expect(persisted.activeModelId).toBe('model');
expect(persisted.messages[0].content).toBe('persist me');
});
});
+42
View File
@@ -0,0 +1,42 @@
import { act, renderHook } from '@testing-library/react-native';
import { useAutoConnect } from '../hooks/useAutoConnect';
import * as api from '../services/lmStudioApi';
import { useAppStore } from '../store/useAppStore';
jest.mock('../services/lmStudioApi');
const mockApi = api as jest.Mocked<typeof api>;
beforeEach(() => {
jest.resetAllMocks();
useAppStore.setState(useAppStore.getInitialState(), true);
});
describe('useAutoConnect', () => {
it('loads the models once on mount and marks the server connected', async () => {
mockApi.getModels.mockResolvedValue([{ id: 'm1', object: 'model', owned_by: 'x' }]);
renderHook(() => useAutoConnect());
await act(async () => {});
expect(mockApi.getModels).toHaveBeenCalledTimes(1);
expect(useAppStore.getState().isConnected).toBe(true);
expect(useAppStore.getState().activeModelId).toBe('m1');
});
it('leaves the app offline when the server does not answer', async () => {
mockApi.getModels.mockRejectedValue(new Error('down'));
renderHook(() => useAutoConnect());
await act(async () => {});
expect(useAppStore.getState().isConnected).toBe(false);
});
it('does not check again on re-render', async () => {
mockApi.getModels.mockResolvedValue([]);
const { rerender } = renderHook(() => useAutoConnect());
await act(async () => {});
rerender({});
await act(async () => {});
expect(mockApi.getModels).toHaveBeenCalledTimes(1);
});
});
+246
View File
@@ -0,0 +1,246 @@
import { act, renderHook } from '@testing-library/react-native';
import { useLMStudio } from '../hooks/useLMStudio';
import * as api from '../services/lmStudioApi';
import { useAppStore } from '../store/useAppStore';
jest.mock('../services/lmStudioApi');
const mockApi = api as jest.Mocked<typeof api>;
const state = () => useAppStore.getState();
type StreamArgs = Parameters<typeof api.chatCompletionStream>;
const lastStreamCall = () => mockApi.chatCompletionStream.mock.calls.at(-1) as StreamArgs;
beforeEach(() => {
jest.resetAllMocks();
useAppStore.setState(useAppStore.getInitialState(), true);
useAppStore.setState({ activeModelId: 'model-a' });
mockApi.chatCompletionStream.mockReturnValue({ abort: jest.fn() });
});
describe('checkConnection', () => {
it('stores the result of the connection test', async () => {
mockApi.testConnection.mockResolvedValue(true);
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await expect(result.current.checkConnection()).resolves.toBe(true);
});
expect(state().isConnected).toBe(true);
});
});
describe('fetchModels', () => {
const models = [
{ id: 'first', object: 'model', owned_by: 'x' },
{ id: 'second', object: 'model', owned_by: 'x' },
];
it('stores the models, marks the server connected and selects the first model if none is active', async () => {
useAppStore.setState({ activeModelId: '' });
mockApi.getModels.mockResolvedValue(models);
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.fetchModels();
});
expect(state().availableModels).toEqual(models);
expect(state().isConnected).toBe(true);
expect(state().activeModelId).toBe('first');
});
it('keeps the model that was already selected', async () => {
mockApi.getModels.mockResolvedValue(models);
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.fetchModels();
});
expect(state().activeModelId).toBe('model-a');
});
it('does not select anything when the server has no models', async () => {
useAppStore.setState({ activeModelId: '' });
mockApi.getModels.mockResolvedValue([]);
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.fetchModels();
});
expect(state().activeModelId).toBe('');
});
it('marks the server offline and clears the list when the request fails', async () => {
useAppStore.setState({ isConnected: true, availableModels: models });
mockApi.getModels.mockRejectedValue(new Error('down'));
const { result } = renderHook(() => useLMStudio());
let returned: unknown;
await act(async () => {
returned = await result.current.fetchModels();
});
expect(returned).toEqual([]);
expect(state().isConnected).toBe(false);
expect(state().availableModels).toEqual([]);
});
});
describe('sendMessage', () => {
it('streams an answer for the conversation, prefixed by the system prompt', async () => {
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.sendMessage('hello');
});
const [, modelId, apiMessages, onChunk, onDone] = lastStreamCall();
expect(modelId).toBe('model-a');
expect(apiMessages[0].role).toBe('system');
expect(apiMessages.slice(1)).toEqual([{ role: 'user', content: 'hello' }]);
expect(state().isStreaming).toBe(true);
act(() => {
onChunk('Hi');
onChunk(' there');
onDone();
});
expect(state().messages.map((m) => `${m.role}:${m.content}`)).toEqual(['user:hello', 'assistant:Hi there']);
expect(state().isStreaming).toBe(false);
expect(state().chatSessions).toHaveLength(1);
});
it('asks for a model instead of calling the server when none is selected', async () => {
useAppStore.setState({ activeModelId: '' });
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.sendMessage('hello');
});
expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
expect(state().messages[1].content).toContain('No model selected');
expect(state().isStreaming).toBe(false);
});
it('ignores new messages while an answer is streaming', async () => {
useAppStore.setState({ isStreaming: true });
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.sendMessage('again');
});
expect(state().messages).toEqual([]);
expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
});
it('writes the error into the assistant message and stops streaming when the stream fails', async () => {
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.sendMessage('hello');
});
const onError = lastStreamCall()[5];
act(() => {
onError(new Error('Could not reach the server'));
});
expect(state().messages[1].content).toContain('Error: Could not reach the server');
expect(state().isStreaming).toBe(false);
expect(state().chatSessions).toHaveLength(1);
});
it('reports a synchronous failure to start the stream', async () => {
mockApi.chatCompletionStream.mockImplementation(() => {
throw new Error('boom');
});
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.sendMessage('hello');
});
expect(state().messages[1].content).toContain('Connection failed: boom');
expect(state().isStreaming).toBe(false);
});
});
describe('regenerateResponse', () => {
it('drops everything after the last user message and asks again', async () => {
state().addMessage({ role: 'user', content: 'q1' });
state().addMessage({ role: 'assistant', content: 'a1' });
state().addMessage({ role: 'user', content: 'q2' });
state().addMessage({ role: 'assistant', content: 'a2' });
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.regenerateResponse();
});
const apiMessages = lastStreamCall()[2];
expect(apiMessages.slice(1).map((m) => m.content)).toEqual(['q1', 'a1', 'q2']);
expect(state().messages.map((m) => m.content)).toEqual(['q1', 'a1', 'q2', '']);
expect(state().isStreaming).toBe(true);
});
it('keeps the current answer and asks for a model when none is selected', async () => {
state().addMessage({ role: 'user', content: 'q' });
state().addMessage({ role: 'assistant', content: 'a' });
useAppStore.setState({ activeModelId: '' });
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.regenerateResponse();
});
expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
expect(state().messages.map((m) => m.content)).toEqual([
'q',
'a',
expect.stringContaining('No model selected'),
]);
});
it('does nothing without a user message or while streaming', async () => {
const { result, rerender } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.regenerateResponse();
});
expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
expect(state().messages).toEqual([]);
act(() => {
state().addMessage({ role: 'user', content: 'q' });
});
act(() => {
useAppStore.setState({ activeModelId: 'model-a', isStreaming: true });
});
rerender({});
await act(async () => {
await result.current.regenerateResponse();
});
expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
});
});
describe('stopStreaming', () => {
it('aborts the request, stops streaming and keeps the partial answer in History', async () => {
const abort = jest.fn();
mockApi.chatCompletionStream.mockReturnValue({ abort });
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.sendMessage('hello');
});
act(() => {
lastStreamCall()[3]('partial');
});
act(() => {
result.current.stopStreaming();
});
expect(abort).toHaveBeenCalledTimes(1);
expect(state().isStreaming).toBe(false);
expect(state().chatSessions[0].messages.at(-1)?.content).toBe('partial');
});
it('is a no-op when nothing is streaming', () => {
const { result } = renderHook(() => useLMStudio());
act(() => {
result.current.stopStreaming();
});
expect(state().chatSessions).toEqual([]);
});
});
+77
View File
@@ -0,0 +1,77 @@
import { ChatMessage } from '../store/useAppStore';
import { findLastAssistantId, shouldShowTypingIndicator } from '../utils/chatSelectors';
import { buildServerConfig, isConfigChanged } from '../utils/serverConfig';
import { isDarkTheme } from '../utils/theme';
const msg = (id: string, role: ChatMessage['role'], content = 'x'): ChatMessage => ({
id,
role,
content,
timestamp: 0,
});
describe('findLastAssistantId', () => {
it('returns the most recent assistant message', () => {
expect(findLastAssistantId([msg('1', 'assistant'), msg('2', 'user'), msg('3', 'assistant')])).toBe('3');
});
it('returns null when there is no assistant message', () => {
expect(findLastAssistantId([msg('1', 'user')])).toBeNull();
expect(findLastAssistantId([])).toBeNull();
});
});
describe('shouldShowTypingIndicator', () => {
it('is true only while streaming an assistant message with no text yet', () => {
expect(shouldShowTypingIndicator([msg('1', 'user'), msg('2', 'assistant', '')], true)).toBe(true);
});
it('is false when not streaming, when text arrived, when the last message is the user, or when empty', () => {
expect(shouldShowTypingIndicator([msg('2', 'assistant', '')], false)).toBe(false);
expect(shouldShowTypingIndicator([msg('2', 'assistant', 'hi')], true)).toBe(false);
expect(shouldShowTypingIndicator([msg('1', 'user', '')], true)).toBe(false);
expect(shouldShowTypingIndicator([], true)).toBe(false);
});
});
describe('buildServerConfig', () => {
it('trims the host and API key and parses the port', () => {
expect(buildServerConfig({ host: ' 10.0.0.1 ', port: '8080', apiKey: ' k ' })).toEqual({
host: '10.0.0.1',
port: 8080,
apiKey: 'k',
});
});
it('uses the defaults for blank host, invalid port and blank key', () => {
expect(buildServerConfig({ host: '', port: 'abc', apiKey: ' ' })).toEqual({
host: 'localhost',
port: 1234,
apiKey: undefined,
});
});
});
describe('isConfigChanged', () => {
const saved = { host: 'lan', port: 1234, apiKey: 'k' };
it('is false when the form matches the saved config', () => {
expect(isConfigChanged({ host: 'lan', port: '1234', apiKey: 'k' }, saved)).toBe(false);
expect(isConfigChanged({ host: 'lan', port: '1234', apiKey: '' }, { host: 'lan', port: 1234 })).toBe(false);
});
it('is true when any single field differs', () => {
expect(isConfigChanged({ host: 'other', port: '1234', apiKey: 'k' }, saved)).toBe(true);
expect(isConfigChanged({ host: 'lan', port: '99', apiKey: 'k' }, saved)).toBe(true);
expect(isConfigChanged({ host: 'lan', port: '1234', apiKey: '' }, saved)).toBe(true);
});
});
describe('isDarkTheme', () => {
it('honours an explicit preference and falls back to the system scheme', () => {
expect(isDarkTheme('dark', 'light')).toBe(true);
expect(isDarkTheme('light', 'dark')).toBe(false);
expect(isDarkTheme('system', 'dark')).toBe(true);
expect(isDarkTheme('system', 'light')).toBe(false);
});
});
+8
View File
@@ -1,4 +1,5 @@
import { ModelSelector } from '@/components/chat/ModelSelector';
import { useAutoConnect } from '@/hooks/useAutoConnect';
import { useThemeColor } from '@/hooks/useThemeColor';
import { useAppStore } from '@/store/useAppStore';
import { Stack, useRouter } from 'expo-router';
@@ -10,6 +11,7 @@ export default function ChatLayout() {
const router = useRouter();
const clearChat = useAppStore(state => state.clearChat);
const colors = useThemeColor();
useAutoConnect();
const isLargeScreen = width > 768;
const containerStyle = [
@@ -36,6 +38,8 @@ export default function ChatLayout() {
<View style={{ flexDirection: 'row', gap: 8 }}>
<TouchableOpacity
onPress={() => router.push('/chat/history')}
accessibilityRole="button"
accessibilityLabel="Chat history"
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
activeOpacity={0.7}
>
@@ -43,6 +47,8 @@ export default function ChatLayout() {
</TouchableOpacity>
<TouchableOpacity
onPress={clearChat}
accessibilityRole="button"
accessibilityLabel="New chat"
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
activeOpacity={0.7}
>
@@ -53,6 +59,8 @@ export default function ChatLayout() {
headerRight: () => (
<TouchableOpacity
onPress={() => router.push('/settings')}
accessibilityRole="button"
accessibilityLabel="Settings"
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
activeOpacity={0.7}
>
+10 -1
View File
@@ -20,7 +20,12 @@ export default function HistoryScreen() {
return (
<View style={[styles.container, { backgroundColor: colors.backgroundColor }]}>
<View style={[styles.header, { borderBottomColor: colors.border, backgroundColor: colors.backgroundColor }]}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<TouchableOpacity
onPress={() => router.back()}
style={styles.backButton}
accessibilityRole="button"
accessibilityLabel="Back"
>
<ArrowLeft size={24} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.title, { color: colors.text }]}>Chat History</Text>
@@ -43,6 +48,8 @@ export default function HistoryScreen() {
{ backgroundColor: colors.cardBackground, borderColor: colors.border }
]}
onPress={() => handleLoadChat(item.id)}
accessibilityRole="button"
accessibilityLabel={`Open chat: ${item.title}`}
>
<View style={styles.chatInfo}>
<Text style={[styles.chatTitle, { color: colors.text }]} numberOfLines={1}>
@@ -55,6 +62,8 @@ export default function HistoryScreen() {
<TouchableOpacity
style={styles.deleteButton}
onPress={() => deleteChatSession(item.id)}
accessibilityRole="button"
accessibilityLabel={`Delete chat: ${item.title}`}
>
<Trash2 size={20} color={colors.errorText} />
</TouchableOpacity>
+17 -176
View File
@@ -1,20 +1,12 @@
import { ChatInput } from '@/components/chat/ChatInput';
import { MessageBubble } from '@/components/chat/MessageBubble';
import { TypingIndicator } from '@/components/chat/TypingIndicator';
import { EmptyState } from '@/components/chat/EmptyState';
import { MessageList } from '@/components/chat/MessageList';
import { OfflineBanner } from '@/components/chat/OfflineBanner';
import { useLMStudio } from '@/hooks/useLMStudio';
import { useThemeColor } from '@/hooks/useThemeColor';
import { useAppStore } from '@/store/useAppStore';
import { MessageSquare, Sparkles } from 'lucide-react-native';
import React, { useCallback, useEffect, useRef } from 'react';
import { FlatList, KeyboardAvoidingView, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Animated, { FadeInDown } from 'react-native-reanimated';
const PROMPT_SUGGESTIONS = [
'Explain recursion in simple terms',
'Write a short poem about coding',
'What are the best practices for React Native?',
'Help me debug a JavaScript error',
];
import React, { useCallback } from 'react';
import { KeyboardAvoidingView, Platform, StyleSheet } from 'react-native';
export default function ChatScreen() {
const colors = useThemeColor(); // Dynamic theme
@@ -25,7 +17,6 @@ export default function ChatScreen() {
const deleteMessage = useAppStore(state => state.deleteMessage);
const editMessage = useAppStore(state => state.editMessage);
const { sendMessage, stopStreaming, regenerateResponse, isStreaming, isConnected } = useLMStudio();
const flatListRef = useRef<FlatList>(null);
// Find the editing message content
const editingMessage = editingMessageId
@@ -37,122 +28,35 @@ export default function ChatScreen() {
editMessage(editingMessageId, text);
deleteMessagesAfter(editingMessageId);
setEditingMessageId(null);
setTimeout(() => {
const store = useAppStore.getState();
const msgs = store.messages;
// Add system prompt before mapped messages
const { SYSTEM_PROMPT } = require('@/constants/SystemPrompt');
const apiMsgs = [
{ role: 'system', content: SYSTEM_PROMPT },
...msgs.map(m => ({ role: m.role, content: m.content }))
];
const modelId = store.activeModelId;
if (!modelId) return;
const assistantId = store.addMessage({ role: 'assistant', content: '' });
store.setIsStreaming(true);
const { chatCompletionStream } = require('@/services/lmStudioApi');
chatCompletionStream(
store.serverConfig,
modelId,
apiMsgs,
(chunk: string) => store.appendToMessage(assistantId, chunk),
() => { store.setIsStreaming(false); },
(error: Error) => {
store.appendToMessage(assistantId, `\n\n⚠️ Error: ${error.message}`);
store.setIsStreaming(false);
},
);
}, 100);
// The edited message is now the last one, so regenerating answers it.
regenerateResponse();
} else {
sendMessage(text);
}
}, [editingMessageId, editingMessage, editMessage, deleteMessagesAfter, setEditingMessageId, sendMessage]);
}, [editingMessageId, editingMessage, editMessage, deleteMessagesAfter, setEditingMessageId, regenerateResponse, sendMessage]);
const handleEdit = useCallback((id: string) => setEditingMessageId(id), [setEditingMessageId]);
const handleCancelEdit = useCallback(() => setEditingMessageId(null), [setEditingMessageId]);
const handleDelete = useCallback((id: string) => deleteMessage(id), [deleteMessage]);
const lastAssistantId = messages.length > 0
? [...messages].reverse().find(m => m.role === 'assistant')?.id
: null;
useEffect(() => {
if (messages.length > 0 && flatListRef.current) {
setTimeout(() => {
flatListRef.current?.scrollToEnd({ animated: !isStreaming });
}, 100);
}
}, [messages.length, messages[messages.length - 1]?.content?.length, isStreaming]);
const showTypingIndicator = isStreaming &&
messages.length > 0 &&
messages[messages.length - 1]?.role === 'assistant' &&
messages[messages.length - 1]?.content === '';
return (
<KeyboardAvoidingView
style={[styles.container, { backgroundColor: colors.backgroundColor }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
>
{!isConnected && (
<View style={[styles.banner, { backgroundColor: colors.bannerBg, borderBottomColor: colors.bannerBg }]}>
<Text style={[styles.bannerText, { color: colors.bannerText }]}>
⚠️ Server offline. Check Settings → Server Connection.
</Text>
</View>
)}
{!isConnected && <OfflineBanner />}
{messages.length === 0 ? (
<View style={[styles.emptyState, { backgroundColor: colors.backgroundColor }]}>
<View style={[styles.emptyIconWrap, { backgroundColor: colors.cardBackground, shadowColor: colors.shadow }]}>
<MessageSquare size={40} color={colors.textMuted} />
</View>
<Text style={[styles.emptyTitle, { color: colors.text }]}>Start a conversation</Text>
<Text style={[styles.emptySubtitle, { color: colors.textMuted }]}>
Send a message to begin chatting with your local AI model.
</Text>
<View style={styles.suggestionsWrap}>
{PROMPT_SUGGESTIONS.map((prompt, i) => (
<TouchableOpacity
key={i}
style={[styles.suggestionChip, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}
onPress={() => sendMessage(prompt)}
activeOpacity={0.7}
>
<Sparkles size={14} color={colors.textMuted} />
<Text style={[styles.suggestionText, { color: colors.text }]} numberOfLines={1}>{prompt}</Text>
</TouchableOpacity>
))}
</View>
</View>
<EmptyState onSelectPrompt={sendMessage} />
) : (
<>
<FlatList
ref={flatListRef}
data={messages}
keyExtractor={(item) => item.id}
renderItem={({ item, index }) => (
<Animated.View entering={FadeInDown.duration(300).delay(index > messages.length - 4 ? 100 : 0)}>
<MessageBubble
message={item}
onEdit={() => handleEdit(item.id)}
onRegenerate={regenerateResponse}
onDelete={() => handleDelete(item.id)}
isStreaming={isStreaming}
isLastAssistant={item.id === lastAssistantId}
/>
</Animated.View>
)}
contentContainerStyle={styles.listContent}
/>
{showTypingIndicator && <TypingIndicator />}
</>
<MessageList
messages={messages}
isStreaming={isStreaming}
onEdit={handleEdit}
onDelete={handleDelete}
onRegenerate={regenerateResponse}
/>
)}
<ChatInput
@@ -170,67 +74,4 @@ const styles = StyleSheet.create({
container: {
flex: 1,
},
listContent: {
paddingTop: 16,
paddingBottom: 24,
},
banner: {
paddingHorizontal: 16,
paddingVertical: 10,
borderBottomWidth: 1,
},
bannerText: {
fontSize: 13,
textAlign: 'center',
fontWeight: '500',
},
emptyState: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
paddingBottom: 80,
},
emptyIconWrap: {
width: 80,
height: 80,
borderRadius: 40,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 20,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.05,
shadowRadius: 10,
elevation: 2,
},
emptyTitle: {
fontSize: 22,
fontWeight: '700',
marginBottom: 8,
},
emptySubtitle: {
fontSize: 15,
textAlign: 'center',
lineHeight: 22,
marginBottom: 32,
},
suggestionsWrap: {
width: '100%',
maxWidth: 400,
gap: 12,
},
suggestionChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
borderWidth: 1,
borderRadius: 16,
paddingHorizontal: 16,
paddingVertical: 14,
},
suggestionText: {
fontSize: 15,
flex: 1,
fontWeight: '500',
},
});
+8 -338
View File
@@ -1,76 +1,14 @@
import { ConnectionBadge } from '@/components/ui/ConnectionBadge';
import { useLMStudio } from '@/hooks/useLMStudio';
import { AboutCard } from '@/components/settings/AboutCard';
import { AppearanceCard } from '@/components/settings/AppearanceCard';
import { ServerConnectionCard } from '@/components/settings/ServerConnectionCard';
import { useThemeColor } from '@/hooks/useThemeColor';
import * as api from '@/services/lmStudioApi';
import { ServerConfig } from '@/services/types';
import { useAppStore } from '@/store/useAppStore';
import { Info, MonitorCog, Moon, Network } from 'lucide-react-native';
import React, { useState } from 'react';
import {
ActivityIndicator,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
TouchableOpacity,
useWindowDimensions,
View,
} from 'react-native';
import React from 'react';
import { ScrollView, StyleSheet, useWindowDimensions } from 'react-native';
export default function SettingsScreen() {
const { width } = useWindowDimensions();
const colors = useThemeColor(); // Get current theme colors
// Global State
const themePreference = useAppStore(state => state.theme);
const setTheme = useAppStore(state => state.setTheme);
const serverConfig = useAppStore(state => state.serverConfig);
const setServerConfig = useAppStore(state => state.setServerConfig);
const isConnected = useAppStore(state => state.isConnected);
const setIsConnected = useAppStore(state => state.setIsConnected);
const { fetchModels } = useLMStudio();
// Local State
const [host, setHost] = useState(serverConfig.host);
const [port, setPort] = useState(serverConfig.port.toString());
const [apiKey, setApiKey] = useState(serverConfig.apiKey ?? '');
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<boolean | null>(null);
const isLargeScreen = width > 768;
const isDarkMode = themePreference === 'dark' || (themePreference === 'system' && colors.backgroundColor === '#09090B'); // Hacky check but robust given our Colors.ts
const toggleTheme = () => setTheme(isDarkMode ? 'light' : 'dark');
const currentConfig: ServerConfig = {
host: host.trim() || 'localhost',
port: parseInt(port, 10) || 1234,
apiKey: apiKey.trim() || undefined,
};
const handleSaveConfig = () => {
setServerConfig(currentConfig);
setTestResult(null);
};
const handleTestConnection = async () => {
handleSaveConfig();
setIsTesting(true);
setTestResult(null);
const ok = await api.testConnection(currentConfig);
setTestResult(ok);
setIsConnected(ok);
if (ok) {
await fetchModels();
}
setIsTesting(false);
};
const configChanged =
host !== serverConfig.host ||
port !== serverConfig.port.toString() ||
apiKey !== (serverConfig.apiKey ?? '');
return (
<ScrollView
@@ -80,151 +18,9 @@ export default function SettingsScreen() {
isLargeScreen && styles.desktopContent,
]}
>
{/* Server Connection Section */}
<View style={[styles.section, isLargeScreen && styles.desktopSection]}>
<Text style={[styles.sectionLabel, { color: colors.textMuted }]}>SERVER CONNECTION</Text>
<View style={[styles.card, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}>
<View style={styles.cardHeader}>
<View style={styles.cardHeaderTitle}>
<Network size={20} color={colors.text} style={styles.headerIcon} />
<Text style={[styles.cardTitle, { color: colors.text }]}>LM Studio API</Text>
</View>
<ConnectionBadge isConnected={isConnected} />
</View>
<View style={[styles.divider, { backgroundColor: colors.border }]} />
<View style={styles.inputGroup}>
<Text style={[styles.label, { color: colors.textMuted }]}>Host IP / Address</Text>
<TextInput
style={[
styles.textInput,
{ backgroundColor: colors.inputBg, borderColor: colors.inputBorder, color: colors.text }
]}
value={host}
onChangeText={setHost}
placeholder="localhost"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
<View style={styles.inputGroup}>
<Text style={[styles.label, { color: colors.textMuted }]}>Port Number</Text>
<TextInput
style={[
styles.textInput,
{ backgroundColor: colors.inputBg, borderColor: colors.inputBorder, color: colors.text }
]}
value={port}
onChangeText={setPort}
placeholder="1234"
placeholderTextColor={colors.textMuted}
keyboardType="numeric"
/>
</View>
<View style={styles.inputGroup}>
<Text style={[styles.label, { color: colors.textMuted }]}>API Key (optional)</Text>
<TextInput
style={[
styles.textInput,
{ backgroundColor: colors.inputBg, borderColor: colors.inputBorder, color: colors.text }
]}
value={apiKey}
onChangeText={setApiKey}
placeholder="Leave empty for local"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
/>
</View>
<View style={styles.buttonRow}>
{configChanged && (
<TouchableOpacity
style={[styles.saveButton, { borderColor: colors.border, backgroundColor: colors.cardBackground }]}
onPress={handleSaveConfig}
>
<Text style={[styles.saveButtonText, { color: colors.text }]}>Save Changes</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={[
styles.testButton,
{ backgroundColor: colors.tint },
isTesting && styles.testButtonDisabled
]}
onPress={handleTestConnection}
disabled={isTesting}
>
{isTesting ? (
<ActivityIndicator size="small" color={colors.backgroundColor} />
) : (
<Text style={[styles.testButtonText, { color: colors.backgroundColor }]}>Test Connection</Text>
)}
</TouchableOpacity>
</View>
{testResult !== null && (
<View style={[
styles.resultBox,
{ backgroundColor: testResult ? colors.successBg : colors.errorBg }
]}>
<Text style={[
styles.resultText,
{ color: testResult ? colors.successText : colors.errorText }
]}>
{testResult
? 'Connection successful. API is ready.'
: 'Connection failed. Verify host and port.'}
</Text>
</View>
)}
</View>
</View>
{/* Appearance Section */}
<View style={[styles.section, isLargeScreen && styles.desktopSection]}>
<Text style={[styles.sectionLabel, { color: colors.textMuted }]}>APPEARANCE</Text>
<View style={[styles.card, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}>
<View style={styles.row}>
<View style={styles.rowIconWrap}>
<Moon size={20} color={colors.text} />
</View>
<View style={styles.rowText}>
<Text style={[styles.rowTitle, { color: colors.text }]}>Dark Mode</Text>
<Text style={[styles.rowSubtitle, { color: colors.textMuted }]}>Toggle sleek dark theme</Text>
</View>
<Switch
value={isDarkMode}
onValueChange={toggleTheme}
trackColor={{ false: '#E2E8F0', true: colors.tint }}
thumbColor={'#FFFFFF'}
/>
</View>
</View>
</View>
{/* About Section */}
<View style={[styles.section, isLargeScreen && styles.desktopSection]}>
<Text style={[styles.sectionLabel, { color: colors.textMuted }]}>ABOUT</Text>
<View style={[styles.card, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}>
<View style={styles.rowGroup}>
<Info size={18} color={colors.textMuted} style={styles.infoIcon} />
<Text style={[styles.infoText, { color: colors.text }]}>LocalMind - Cross Platform Version</Text>
</View>
<View style={[styles.rowGroup, { marginTop: 8 }]}>
<MonitorCog size={18} color={colors.textMuted} style={styles.infoIcon} />
<Text style={[styles.infoText, { color: colors.textMuted, fontSize: 13 }]}>
Connected via OpenAPI `/v1/chat/completions` specs
</Text>
</View>
</View>
</View>
<ServerConnectionCard />
<AppearanceCard />
<AboutCard />
</ScrollView>
);
}
@@ -241,130 +37,4 @@ const styles = StyleSheet.create({
alignItems: 'center',
paddingTop: 40,
},
section: {
marginBottom: 24,
},
desktopSection: {
width: '100%',
maxWidth: 600,
},
sectionLabel: {
fontSize: 13,
fontWeight: '600',
marginLeft: 16,
marginBottom: 8,
letterSpacing: 0.5,
},
card: {
borderRadius: 16,
padding: 16,
borderWidth: 1,
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
},
cardHeaderTitle: {
flexDirection: 'row',
alignItems: 'center',
},
headerIcon: {
marginRight: 8,
},
cardTitle: {
fontSize: 18,
fontWeight: '700',
},
divider: {
height: 1,
width: '100%',
marginBottom: 16,
},
inputGroup: {
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: '500',
marginBottom: 8,
},
textInput: {
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 12,
fontSize: 16,
},
buttonRow: {
flexDirection: 'row',
gap: 12,
marginTop: 8,
},
saveButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
borderWidth: 1,
},
saveButtonText: {
fontSize: 15,
fontWeight: '600',
},
testButton: {
flex: 2,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
},
testButtonDisabled: {
opacity: 0.7,
},
testButtonText: {
fontSize: 15,
fontWeight: '600',
},
resultBox: {
marginTop: 16,
borderRadius: 12,
padding: 14,
},
resultText: {
fontSize: 14,
textAlign: 'center',
fontWeight: '500',
},
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 4,
},
rowIconWrap: {
marginRight: 12,
},
rowText: {
flex: 1,
},
rowTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
rowSubtitle: {
fontSize: 14,
},
rowGroup: {
flexDirection: 'row',
alignItems: 'center',
},
infoIcon: {
marginRight: 10,
},
infoText: {
fontSize: 15,
lineHeight: 22,
},
});
+12 -1
View File
@@ -65,7 +65,12 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
{isEditing && (
<View style={[styles.editBanner, { backgroundColor: colors.bannerBg, borderBottomColor: colors.border }]}>
<Text style={[styles.editBannerText, { color: colors.bannerText }]}>Editing message</Text>
<TouchableOpacity onPress={handleCancelEdit} style={styles.editCancelBtn}>
<TouchableOpacity
onPress={handleCancelEdit}
style={styles.editCancelBtn}
accessibilityRole="button"
accessibilityLabel="Cancel editing"
>
<X size={16} color={colors.textMuted} />
</TouchableOpacity>
</View>
@@ -80,6 +85,7 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="Send a message..."
accessibilityLabel="Message"
placeholderTextColor={colors.textMuted}
value={text}
onChangeText={setText}
@@ -94,6 +100,8 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
style={[styles.stopButton, { backgroundColor: colors.errorText }]}
onPress={handleStop}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel="Stop generating"
>
<Square size={16} color={colors.backgroundColor} />
</TouchableOpacity>
@@ -106,6 +114,9 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
onPress={handleSend}
disabled={!text.trim()}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel="Send message"
accessibilityState={{ disabled: !text.trim() }}
>
<Send size={18} color={text.trim() ? colors.backgroundColor : colors.textMuted} />
</TouchableOpacity>
+77
View File
@@ -0,0 +1,77 @@
import { Check, Copy } from 'lucide-react-native';
import React from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
interface CodeBlockProps {
language: string;
content: string;
copied: boolean;
onCopy: (text: string) => void;
}
export function CodeBlock({ language, content, copied, onCopy }: CodeBlockProps) {
const CopyIcon = copied ? Check : Copy;
return (
<View style={styles.codeBlockWrapper}>
<View style={styles.codeBlockHeader}>
<Text style={styles.codeBlockLanguage}>{language}</Text>
<TouchableOpacity
onPress={() => onCopy(content)}
style={styles.copyButton}
accessibilityRole="button"
>
<CopyIcon size={14} color="#A0A0A0" />
<Text style={styles.copyText}>{copied ? 'Copied!' : 'Copy'}</Text>
</TouchableOpacity>
</View>
<ScrollView horizontal style={styles.codeScrollView} showsHorizontalScrollIndicator={false}>
<Text style={styles.codeText}>{content}</Text>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
codeBlockWrapper: {
backgroundColor: '#1E1E1E',
borderRadius: 8,
marginVertical: 8,
overflow: 'hidden',
width: '100%',
},
codeBlockHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#2D2D2D',
paddingHorizontal: 12,
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: '#404040',
},
codeBlockLanguage: {
color: '#A0A0A0',
fontSize: 12,
fontFamily: 'monospace',
textTransform: 'lowercase',
},
copyButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
padding: 4,
},
copyText: {
color: '#A0A0A0',
fontSize: 12,
},
codeScrollView: {
padding: 12,
},
codeText: {
color: '#D4D4D4',
fontSize: 13,
fontFamily: 'monospace',
},
});
+99
View File
@@ -0,0 +1,99 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import { MessageSquare, Sparkles } from 'lucide-react-native';
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
const PROMPT_SUGGESTIONS = [
'Explain recursion in simple terms',
'Write a short poem about coding',
'What are the best practices for React Native?',
'Help me debug a JavaScript error',
];
interface EmptyStateProps {
onSelectPrompt: (prompt: string) => void;
}
export function EmptyState({ onSelectPrompt }: EmptyStateProps) {
const colors = useThemeColor();
return (
<View style={[styles.emptyState, { backgroundColor: colors.backgroundColor }]}>
<View style={[styles.emptyIconWrap, { backgroundColor: colors.cardBackground, shadowColor: colors.shadow }]}>
<MessageSquare size={40} color={colors.textMuted} />
</View>
<Text style={[styles.emptyTitle, { color: colors.text }]}>Start a conversation</Text>
<Text style={[styles.emptySubtitle, { color: colors.textMuted }]}>
Send a message to begin chatting with your local AI model.
</Text>
<View style={styles.suggestionsWrap}>
{PROMPT_SUGGESTIONS.map((prompt) => (
<TouchableOpacity
key={prompt}
style={[styles.suggestionChip, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}
onPress={() => onSelectPrompt(prompt)}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel={prompt}
>
<Sparkles size={14} color={colors.textMuted} />
<Text style={[styles.suggestionText, { color: colors.text }]} numberOfLines={1}>{prompt}</Text>
</TouchableOpacity>
))}
</View>
</View>
);
}
const styles = StyleSheet.create({
emptyState: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
paddingBottom: 80,
},
emptyIconWrap: {
width: 80,
height: 80,
borderRadius: 40,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 20,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.05,
shadowRadius: 10,
elevation: 2,
},
emptyTitle: {
fontSize: 22,
fontWeight: '700',
marginBottom: 8,
},
emptySubtitle: {
fontSize: 15,
textAlign: 'center',
lineHeight: 22,
marginBottom: 32,
},
suggestionsWrap: {
width: '100%',
maxWidth: 400,
gap: 12,
},
suggestionChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
borderWidth: 1,
borderRadius: 16,
paddingHorizontal: 16,
paddingVertical: 14,
},
suggestionText: {
fontSize: 15,
flex: 1,
fontWeight: '500',
},
});
+4 -4
View File
@@ -50,24 +50,24 @@ export function MessageActions({
return (
<View style={[styles.container, isUser ? styles.containerUser : styles.containerAssistant]}>
<TouchableOpacity style={styles.actionBtn} onPress={handleCopy} activeOpacity={0.6}>
<TouchableOpacity style={styles.actionBtn} onPress={handleCopy} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Copy message">
<Copy size={16} color={colors.textMuted} />
</TouchableOpacity>
{isUser && onEdit && (
<TouchableOpacity style={styles.actionBtn} onPress={handleEdit} activeOpacity={0.6}>
<TouchableOpacity style={styles.actionBtn} onPress={handleEdit} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Edit message">
<Pencil size={16} color={colors.textMuted} />
</TouchableOpacity>
)}
{!isUser && onRegenerate && (
<TouchableOpacity style={styles.actionBtn} onPress={handleRegenerate} activeOpacity={0.6}>
<TouchableOpacity style={styles.actionBtn} onPress={handleRegenerate} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Regenerate response">
<RefreshCw size={16} color={colors.textMuted} />
</TouchableOpacity>
)}
{onDelete && (
<TouchableOpacity style={styles.actionBtn} onPress={handleDelete} activeOpacity={0.6}>
<TouchableOpacity style={styles.actionBtn} onPress={handleDelete} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Delete message">
<Trash2 size={16} color={colors.textMuted} />
</TouchableOpacity>
)}
+62 -260
View File
@@ -1,11 +1,10 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import { ChatMessage } from '@/store/useAppStore';
import Markdown from '@ronradtke/react-native-markdown-display';
import * as Clipboard from 'expo-clipboard';
import { Bot, Check, Copy, User } from 'lucide-react-native';
import React, { useMemo, useState } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native';
import { Bot, User } from 'lucide-react-native';
import React, { useState } from 'react';
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native';
import { MessageActions } from './MessageActions';
import { MessageContent } from './MessageContent';
interface MessageBubbleProps {
message: ChatMessage;
@@ -16,6 +15,58 @@ interface MessageBubbleProps {
isLastAssistant?: boolean;
}
function Avatar({ isUser }: { isUser: boolean }) {
const colors = useThemeColor();
const Icon = isUser ? User : Bot;
return (
<View style={[styles.avatar, { backgroundColor: isUser ? colors.tint : colors.border }]}>
<Icon size={16} color={isUser ? colors.backgroundColor : colors.text} />
</View>
);
}
function BubbleRow({ message, showingStreaming }: { message: ChatMessage; showingStreaming: boolean }) {
const { width } = useWindowDimensions();
const colors = useThemeColor();
const isUser = message.role === 'user';
const maxWidth = width > 768 ? '70%' : '85%';
return (
<View style={[styles.container, isUser ? styles.containerUser : styles.containerAssistant]}>
{!isUser && <Avatar isUser={false} />}
<View style={[
styles.bubble,
isUser ? styles.bubbleUser : styles.bubbleAssistant,
{ maxWidth },
{ backgroundColor: isUser ? colors.messageUserBg : colors.messageAssistantBg },
!isUser && { borderWidth: 1, borderColor: colors.border }
]}>
<MessageContent message={message} showingStreaming={showingStreaming} />
</View>
{isUser && <Avatar isUser />}
</View>
);
}
/** Timestamp + edited badge. */
function BubbleMeta({ message }: { message: ChatMessage }) {
const colors = useThemeColor();
const isUser = message.role === 'user';
return (
<View style={[styles.metaRow, isUser ? styles.metaRowUser : styles.metaRowAssistant]}>
{!isUser && <View style={{ width: 32 }} />}
<Text style={[styles.timestamp, { color: colors.textMuted }]}>
{new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
{message.isEdited ? ' · edited' : ''}
</Text>
</View>
);
}
export function MessageBubble({
message,
onEdit,
@@ -24,217 +75,21 @@ export function MessageBubble({
isStreaming,
isLastAssistant,
}: MessageBubbleProps) {
const { width } = useWindowDimensions();
const colors = useThemeColor();
const isLargeScreen = width > 768;
const isUser = message.role === 'user';
const [showActions, setShowActions] = useState(false);
const [copiedText, setCopiedText] = useState<string | null>(null);
const handleCopy = async (text: string) => {
await Clipboard.setStringAsync(text);
setCopiedText(text);
setTimeout(() => setCopiedText(null), 2000);
};
const showingStreaming = isStreaming && isLastAssistant;
const shouldShowActions = !isUser || showActions;
// UseMemo for dynamic colors
const markdownStyles = useMemo(() => ({
body: {
fontSize: 15,
lineHeight: 22,
color: colors.messageAssistantText,
},
strong: {
fontWeight: '700',
color: colors.text,
},
em: {
fontStyle: 'italic',
},
code_inline: {
backgroundColor: colors.backgroundColor,
color: colors.text,
paddingHorizontal: 5,
paddingVertical: 1,
borderRadius: 4,
fontSize: 13,
fontFamily: 'monospace',
},
fence: {
backgroundColor: colors.inputBg,
borderColor: colors.border,
borderWidth: 1,
color: colors.text,
padding: 12,
borderRadius: 8,
fontSize: 13,
fontFamily: 'monospace',
marginVertical: 8,
overflow: 'hidden',
},
code_block: {
backgroundColor: colors.inputBg,
color: colors.text,
padding: 12,
borderRadius: 8,
fontSize: 13,
fontFamily: 'monospace',
marginVertical: 8,
},
blockquote: {
borderLeftWidth: 3,
borderLeftColor: colors.border,
paddingLeft: 12,
marginVertical: 4,
color: colors.textMuted,
},
bullet_list: {
marginVertical: 4,
},
ordered_list: {
marginVertical: 4,
},
list_item: {
marginVertical: 2,
color: colors.messageAssistantText,
},
heading1: {
fontSize: 22,
fontWeight: '700',
marginVertical: 8,
color: colors.text,
},
heading2: {
fontSize: 19,
fontWeight: '700',
marginVertical: 6,
color: colors.text,
},
heading3: {
fontSize: 17,
fontWeight: '600',
marginVertical: 4,
color: colors.text,
},
link: {
color: '#3B82F6',
textDecorationLine: 'underline',
},
hr: {
backgroundColor: colors.border,
height: 1,
marginVertical: 12,
},
paragraph: {
marginVertical: 2,
},
} as any), [colors]); // using 'as any' to satisfy internal Markdown types if needed
const markdownRules = useMemo(() => ({
fence: (node: any, children: any, parent: any, parentStyles: any) => {
const content = node.content || '';
const language = node.sourceInfo || 'code';
return (
<View key={node.key} style={styles.codeBlockWrapper}>
<View style={styles.codeBlockHeader}>
<Text style={styles.codeBlockLanguage}>{language}</Text>
<TouchableOpacity onPress={() => handleCopy(content)} style={styles.copyButton}>
{copiedText === content ? <Check size={14} color="#A0A0A0" /> : <Copy size={14} color="#A0A0A0" />}
<Text style={styles.copyText}>{copiedText === content ? 'Copied!' : 'Copy'}</Text>
</TouchableOpacity>
</View>
<ScrollView horizontal style={styles.codeScrollView} showsHorizontalScrollIndicator={false}>
<Text style={styles.codeText}>{content}</Text>
</ScrollView>
</View>
);
},
code_block: (node: any, children: any, parent: any, parentStyles: any) => {
const content = node.content || '';
return (
<View key={node.key} style={styles.codeBlockWrapper}>
<View style={styles.codeBlockHeader}>
<Text style={styles.codeBlockLanguage}>code</Text>
<TouchableOpacity onPress={() => handleCopy(content)} style={styles.copyButton}>
{copiedText === content ? <Check size={14} color="#A0A0A0" /> : <Copy size={14} color="#A0A0A0" />}
<Text style={styles.copyText}>{copiedText === content ? 'Copied!' : 'Copy'}</Text>
</TouchableOpacity>
</View>
<ScrollView horizontal style={styles.codeScrollView} showsHorizontalScrollIndicator={false}>
<Text style={styles.codeText}>{content}</Text>
</ScrollView>
</View>
);
}
}), [copiedText]);
const renderContent = () => {
if (isUser) {
return (
<Text style={[styles.text, { color: colors.messageUserText }]}>
{message.content}
</Text>
);
}
if (!message.content && showingStreaming) {
return null; // TypingIndicator handled externally
}
return (
<Markdown style={markdownStyles} rules={markdownRules}>
{message.content}
</Markdown>
);
};
const showingStreaming = !!isStreaming && !!isLastAssistant;
const actionsVisible = (!isUser || showActions) && message.content.length > 0;
return (
<View
onTouchStart={() => isUser && setShowActions(true)}
style={styles.outerWrap}
>
<View style={[
styles.container,
isUser ? styles.containerUser : styles.containerAssistant,
]}>
{/* Avatar */}
{!isUser && (
<View style={[styles.avatarAssistant, { backgroundColor: colors.border }]}>
<Bot size={16} color={colors.text} />
</View>
)}
<BubbleRow message={message} showingStreaming={showingStreaming} />
<BubbleMeta message={message} />
<View style={[
styles.bubble,
isUser ? styles.bubbleUser : styles.bubbleAssistant,
{ maxWidth: isLargeScreen ? '70%' : '85%' },
{ backgroundColor: isUser ? colors.messageUserBg : colors.messageAssistantBg },
!isUser && { borderWidth: 1, borderColor: colors.border }
]}>
{renderContent()}
</View>
{isUser && (
<View style={[styles.avatarUser, { backgroundColor: colors.tint }]}>
<User size={16} color={colors.backgroundColor} />
</View>
)}
</View>
{/* Timestamp + edited badge */}
<View style={[styles.metaRow, isUser ? styles.metaRowUser : styles.metaRowAssistant]}>
{!isUser && <View style={{ width: 32 }} />}
<Text style={[styles.timestamp, { color: colors.textMuted }]}>
{new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
{message.isEdited ? ' · edited' : ''}
</Text>
</View>
{/* Action buttons */}
{(shouldShowActions || !isUser) && message.content.length > 0 && (
{actionsVisible && (
<MessageActions
role={message.role}
content={message.content}
@@ -266,15 +121,7 @@ const styles = StyleSheet.create({
containerAssistant: {
justifyContent: 'flex-start',
},
avatarAssistant: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 2,
},
avatarUser: {
avatar: {
width: 32,
height: 32,
borderRadius: 16,
@@ -295,10 +142,6 @@ const styles = StyleSheet.create({
bubbleAssistant: {
borderBottomLeftRadius: 6,
},
text: {
fontSize: 15,
lineHeight: 22,
},
metaRow: {
flexDirection: 'row',
paddingHorizontal: 16,
@@ -316,45 +159,4 @@ const styles = StyleSheet.create({
fontSize: 11,
fontWeight: '500',
},
codeBlockWrapper: {
backgroundColor: '#1E1E1E',
borderRadius: 8,
marginVertical: 8,
overflow: 'hidden',
width: '100%',
},
codeBlockHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#2D2D2D',
paddingHorizontal: 12,
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: '#404040',
},
codeBlockLanguage: {
color: '#A0A0A0',
fontSize: 12,
fontFamily: 'monospace',
textTransform: 'lowercase',
},
copyButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
padding: 4,
},
copyText: {
color: '#A0A0A0',
fontSize: 12,
},
codeScrollView: {
padding: 12,
},
codeText: {
color: '#D4D4D4',
fontSize: 13,
fontFamily: 'monospace',
},
});
+73
View File
@@ -0,0 +1,73 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import { ChatMessage } from '@/store/useAppStore';
import Markdown from '@ronradtke/react-native-markdown-display';
import * as Clipboard from 'expo-clipboard';
import React, { useMemo, useState } from 'react';
import { StyleSheet, Text } from 'react-native';
import { CodeBlock } from './CodeBlock';
import { createMarkdownStyles } from './markdownStyles';
interface MessageContentProps {
message: ChatMessage;
/** True while this message is the answer currently being streamed. */
showingStreaming: boolean;
}
export function MessageContent({ message, showingStreaming }: MessageContentProps) {
const colors = useThemeColor();
const [copiedText, setCopiedText] = useState<string | null>(null);
const handleCopy = async (text: string) => {
await Clipboard.setStringAsync(text);
setCopiedText(text);
setTimeout(() => setCopiedText(null), 2000);
};
const markdownStyles = useMemo(() => createMarkdownStyles(colors), [colors]);
const markdownRules = useMemo(() => ({
fence: (node: any) => (
<CodeBlock
key={node.key}
language={node.sourceInfo || 'code'}
content={node.content || ''}
copied={copiedText === (node.content || '')}
onCopy={handleCopy}
/>
),
code_block: (node: any) => (
<CodeBlock
key={node.key}
language="code"
content={node.content || ''}
copied={copiedText === (node.content || '')}
onCopy={handleCopy}
/>
),
}), [copiedText]);
if (message.role === 'user') {
return (
<Text style={[styles.text, { color: colors.messageUserText }]}>
{message.content}
</Text>
);
}
if (!message.content && showingStreaming) {
return null; // TypingIndicator handled externally
}
return (
<Markdown style={markdownStyles} rules={markdownRules}>
{message.content}
</Markdown>
);
}
const styles = StyleSheet.create({
text: {
fontSize: 15,
lineHeight: 22,
},
});
+94
View File
@@ -0,0 +1,94 @@
import { MessageBubble } from '@/components/chat/MessageBubble';
import { TypingIndicator } from '@/components/chat/TypingIndicator';
import { ChatMessage } from '@/store/useAppStore';
import { findLastAssistantId, shouldShowTypingIndicator } from '@/utils/chatSelectors';
import React, { useEffect, useRef } from 'react';
import { FlatList, StyleSheet } from 'react-native';
import Animated, { FadeInDown } from 'react-native-reanimated';
interface MessageListProps {
messages: ChatMessage[];
isStreaming: boolean;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
onRegenerate: () => void;
}
interface MessageRowProps {
message: ChatMessage;
delayed: boolean;
isStreaming: boolean;
isLastAssistant: boolean;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
onRegenerate: () => void;
}
// Memoized so that a streamed token only re-renders (and re-parses the Markdown of)
// the message that changed, not the whole conversation.
const MessageRow = React.memo(function MessageRow({
message,
delayed,
isStreaming,
isLastAssistant,
onEdit,
onDelete,
onRegenerate,
}: MessageRowProps) {
return (
<Animated.View entering={FadeInDown.duration(300).delay(delayed ? 100 : 0)}>
<MessageBubble
message={message}
onEdit={() => onEdit(message.id)}
onRegenerate={onRegenerate}
onDelete={() => onDelete(message.id)}
isStreaming={isStreaming}
isLastAssistant={isLastAssistant}
/>
</Animated.View>
);
});
export function MessageList({ messages, isStreaming, onEdit, onDelete, onRegenerate }: MessageListProps) {
const flatListRef = useRef<FlatList>(null);
const lastAssistantId = findLastAssistantId(messages);
const lastContentLength = messages[messages.length - 1]?.content?.length;
useEffect(() => {
if (messages.length > 0 && flatListRef.current) {
setTimeout(() => {
flatListRef.current?.scrollToEnd({ animated: !isStreaming });
}, 100);
}
}, [messages.length, lastContentLength, isStreaming]);
return (
<>
<FlatList
ref={flatListRef}
data={messages}
keyExtractor={(item) => item.id}
renderItem={({ item, index }) => (
<MessageRow
message={item}
delayed={index > messages.length - 4}
isStreaming={isStreaming}
isLastAssistant={item.id === lastAssistantId}
onEdit={onEdit}
onDelete={onDelete}
onRegenerate={onRegenerate}
/>
)}
contentContainerStyle={styles.listContent}
/>
{shouldShowTypingIndicator(messages, isStreaming) && <TypingIndicator />}
</>
);
}
const styles = StyleSheet.create({
listContent: {
paddingTop: 16,
paddingBottom: 24,
},
});
+7
View File
@@ -42,6 +42,8 @@ export function ModelSelector() {
style={[styles.headerButton, { backgroundColor: colors.cardBackground }]}
onPress={() => setModalVisible(true)}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel={`Model: ${activeModelName}. Tap to change`}
>
<ConnectionBadge isConnected={isConnected} compact />
<Text style={[styles.headerText, { color: colors.text }]} numberOfLines={1}>
@@ -70,6 +72,8 @@ export function ModelSelector() {
onPress={handleRefresh}
style={[styles.refreshButton, { backgroundColor: colors.cardBackground }]}
disabled={isRefreshing}
accessibilityRole="button"
accessibilityLabel="Refresh models"
>
{isRefreshing ? (
<ActivityIndicator size="small" color={colors.textMuted} />
@@ -108,6 +112,9 @@ export function ModelSelector() {
isSelected && { borderColor: colors.tint }
]}
onPress={() => handleSelect(item.id)}
accessibilityRole="button"
accessibilityLabel={item.id}
accessibilityState={{ selected: isSelected }}
>
<View style={styles.itemContent}>
<Text style={[styles.itemName, { color: colors.text }]}>
+31
View File
@@ -0,0 +1,31 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
export function OfflineBanner() {
const colors = useThemeColor();
return (
<View
accessibilityRole="alert"
style={[styles.banner, { backgroundColor: colors.bannerBg, borderBottomColor: colors.bannerBg }]}
>
<Text style={[styles.bannerText, { color: colors.bannerText }]}>
⚠️ Server offline. Check Settings → Server Connection.
</Text>
</View>
);
}
const styles = StyleSheet.create({
banner: {
paddingHorizontal: 16,
paddingVertical: 10,
borderBottomWidth: 1,
},
bannerText: {
fontSize: 13,
textAlign: 'center',
fontWeight: '500',
},
});
+99
View File
@@ -0,0 +1,99 @@
import { useThemeColor } from '@/hooks/useThemeColor';
type ThemeColors = ReturnType<typeof useThemeColor>;
/** Style map for the Markdown renderer, derived from the active theme colours. */
export function createMarkdownStyles(colors: ThemeColors) {
return {
body: {
fontSize: 15,
lineHeight: 22,
color: colors.messageAssistantText,
},
strong: {
fontWeight: '700',
color: colors.text,
},
em: {
fontStyle: 'italic',
},
code_inline: {
backgroundColor: colors.backgroundColor,
color: colors.text,
paddingHorizontal: 5,
paddingVertical: 1,
borderRadius: 4,
fontSize: 13,
fontFamily: 'monospace',
},
fence: {
backgroundColor: colors.inputBg,
borderColor: colors.border,
borderWidth: 1,
color: colors.text,
padding: 12,
borderRadius: 8,
fontSize: 13,
fontFamily: 'monospace',
marginVertical: 8,
overflow: 'hidden',
},
code_block: {
backgroundColor: colors.inputBg,
color: colors.text,
padding: 12,
borderRadius: 8,
fontSize: 13,
fontFamily: 'monospace',
marginVertical: 8,
},
blockquote: {
borderLeftWidth: 3,
borderLeftColor: colors.border,
paddingLeft: 12,
marginVertical: 4,
color: colors.textMuted,
},
bullet_list: {
marginVertical: 4,
},
ordered_list: {
marginVertical: 4,
},
list_item: {
marginVertical: 2,
color: colors.messageAssistantText,
},
heading1: {
fontSize: 22,
fontWeight: '700',
marginVertical: 8,
color: colors.text,
},
heading2: {
fontSize: 19,
fontWeight: '700',
marginVertical: 6,
color: colors.text,
},
heading3: {
fontSize: 17,
fontWeight: '600',
marginVertical: 4,
color: colors.text,
},
link: {
color: '#3B82F6',
textDecorationLine: 'underline',
},
hr: {
backgroundColor: colors.border,
height: 1,
marginVertical: 12,
},
paragraph: {
marginVertical: 2,
},
// The Markdown renderer's style types do not accept every RN style key literal.
} as any;
}
-25
View File
@@ -1,25 +0,0 @@
import { Href, Link } from 'expo-router';
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
import { type ComponentProps } from 'react';
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
export function ExternalLink({ href, ...rest }: Props) {
return (
<Link
target="_blank"
{...rest}
href={href}
onPress={async (event) => {
if (process.env.EXPO_OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
event.preventDefault();
// Open the link in an in-app browser.
await openBrowserAsync(href, {
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
});
}
}}
/>
);
}
-18
View File
@@ -1,18 +0,0 @@
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
import { PlatformPressable } from '@react-navigation/elements';
import * as Haptics from 'expo-haptics';
export function HapticTab(props: BottomTabBarButtonProps) {
return (
<PlatformPressable
{...props}
onPressIn={(ev) => {
if (process.env.EXPO_OS === 'ios') {
// Add a soft haptic feedback when pressing down on the tabs.
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
props.onPressIn?.(ev);
}}
/>
);
}
-19
View File
@@ -1,19 +0,0 @@
import Animated from 'react-native-reanimated';
export function HelloWave() {
return (
<Animated.Text
style={{
fontSize: 28,
lineHeight: 32,
marginTop: -6,
animationName: {
'50%': { transform: [{ rotate: '25deg' }] },
},
animationIterationCount: 4,
animationDuration: '300ms',
}}>
👋
</Animated.Text>
);
}
-79
View File
@@ -1,79 +0,0 @@
import type { PropsWithChildren, ReactElement } from 'react';
import { StyleSheet } from 'react-native';
import Animated, {
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollOffset,
} from 'react-native-reanimated';
import { ThemedView } from '@/components/themed-view';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { useThemeColor } from '@/hooks/use-theme-color';
const HEADER_HEIGHT = 250;
type Props = PropsWithChildren<{
headerImage: ReactElement;
headerBackgroundColor: { dark: string; light: string };
}>;
export default function ParallaxScrollView({
children,
headerImage,
headerBackgroundColor,
}: Props) {
const backgroundColor = useThemeColor({}, 'background');
const colorScheme = useColorScheme() ?? 'light';
const scrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollOffset(scrollRef);
const headerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [
{
translateY: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
),
},
{
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
},
],
};
});
return (
<Animated.ScrollView
ref={scrollRef}
style={{ backgroundColor, flex: 1 }}
scrollEventThrottle={16}>
<Animated.View
style={[
styles.header,
{ backgroundColor: headerBackgroundColor[colorScheme] },
headerAnimatedStyle,
]}>
{headerImage}
</Animated.View>
<ThemedView style={styles.content}>{children}</ThemedView>
</Animated.ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
height: HEADER_HEIGHT,
overflow: 'hidden',
},
content: {
flex: 1,
padding: 32,
gap: 16,
overflow: 'hidden',
},
});
+38
View File
@@ -0,0 +1,38 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import { Info, MonitorCog } from 'lucide-react-native';
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { SettingsSection } from './SettingsSection';
export function AboutCard() {
const colors = useThemeColor();
return (
<SettingsSection title="ABOUT">
<View style={styles.rowGroup}>
<Info size={18} color={colors.textMuted} style={styles.infoIcon} />
<Text style={[styles.infoText, { color: colors.text }]}>LocalMind - Cross Platform Version</Text>
</View>
<View style={[styles.rowGroup, { marginTop: 8 }]}>
<MonitorCog size={18} color={colors.textMuted} style={styles.infoIcon} />
<Text style={[styles.infoText, { color: colors.textMuted, fontSize: 13 }]}>
Connected via OpenAPI `/v1/chat/completions` specs
</Text>
</View>
</SettingsSection>
);
}
const styles = StyleSheet.create({
rowGroup: {
flexDirection: 'row',
alignItems: 'center',
},
infoIcon: {
marginRight: 10,
},
infoText: {
fontSize: 15,
lineHeight: 22,
},
});
+62
View File
@@ -0,0 +1,62 @@
import { useColorScheme } from '@/hooks/useColorScheme';
import { useThemeColor } from '@/hooks/useThemeColor';
import { useAppStore } from '@/store/useAppStore';
import { isDarkTheme } from '@/utils/theme';
import { Moon } from 'lucide-react-native';
import React from 'react';
import { StyleSheet, Switch, Text, View } from 'react-native';
import { SettingsSection } from './SettingsSection';
export function AppearanceCard() {
const colors = useThemeColor();
const systemScheme = useColorScheme();
const themePreference = useAppStore(state => state.theme);
const setTheme = useAppStore(state => state.setTheme);
const isDarkMode = isDarkTheme(themePreference, systemScheme);
const toggleTheme = () => setTheme(isDarkMode ? 'light' : 'dark');
return (
<SettingsSection title="APPEARANCE">
<View style={styles.row}>
<View style={styles.rowIconWrap}>
<Moon size={20} color={colors.text} />
</View>
<View style={styles.rowText}>
<Text style={[styles.rowTitle, { color: colors.text }]}>Dark Mode</Text>
<Text style={[styles.rowSubtitle, { color: colors.textMuted }]}>Toggle sleek dark theme</Text>
</View>
<Switch
value={isDarkMode}
onValueChange={toggleTheme}
accessibilityLabel="Dark Mode"
trackColor={{ false: '#E2E8F0', true: colors.tint }}
thumbColor={'#FFFFFF'}
/>
</View>
</SettingsSection>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 4,
},
rowIconWrap: {
marginRight: 12,
},
rowText: {
flex: 1,
},
rowTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
rowSubtitle: {
fontSize: 14,
},
});
+44
View File
@@ -0,0 +1,44 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import React from 'react';
import { StyleSheet, Text, TextInput, TextInputProps, View } from 'react-native';
interface LabeledInputProps extends TextInputProps {
label: string;
}
export function LabeledInput({ label, style, ...inputProps }: LabeledInputProps) {
const colors = useThemeColor();
return (
<View style={styles.inputGroup}>
<Text style={[styles.label, { color: colors.textMuted }]}>{label}</Text>
<TextInput
style={[
styles.textInput,
{ backgroundColor: colors.inputBg, borderColor: colors.inputBorder, color: colors.text },
style,
]}
placeholderTextColor={colors.textMuted}
{...inputProps}
/>
</View>
);
}
const styles = StyleSheet.create({
inputGroup: {
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: '500',
marginBottom: 8,
},
textInput: {
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 12,
fontSize: 16,
},
});
@@ -0,0 +1,194 @@
import { ConnectionBadge } from '@/components/ui/ConnectionBadge';
import { useLMStudio } from '@/hooks/useLMStudio';
import { useThemeColor } from '@/hooks/useThemeColor';
import * as api from '@/services/lmStudioApi';
import { useAppStore } from '@/store/useAppStore';
import { buildServerConfig, isConfigChanged } from '@/utils/serverConfig';
import { Network } from 'lucide-react-native';
import React, { useState } from 'react';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { LabeledInput } from './LabeledInput';
import { SettingsSection } from './SettingsSection';
function TestResultBox({ ok }: { ok: boolean }) {
const colors = useThemeColor();
return (
<View style={[styles.resultBox, { backgroundColor: ok ? colors.successBg : colors.errorBg }]}>
<Text style={[styles.resultText, { color: ok ? colors.successText : colors.errorText }]}>
{ok
? 'Connection successful. API is ready.'
: 'Connection failed. Verify host and port.'}
</Text>
</View>
);
}
export function ServerConnectionCard() {
const colors = useThemeColor();
const serverConfig = useAppStore(state => state.serverConfig);
const setServerConfig = useAppStore(state => state.setServerConfig);
const isConnected = useAppStore(state => state.isConnected);
const setIsConnected = useAppStore(state => state.setIsConnected);
const { fetchModels } = useLMStudio();
const [host, setHost] = useState(serverConfig.host);
const [port, setPort] = useState(serverConfig.port.toString());
const [apiKey, setApiKey] = useState(serverConfig.apiKey ?? '');
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<boolean | null>(null);
const form = { host, port, apiKey };
const currentConfig = buildServerConfig(form);
const configChanged = isConfigChanged(form, serverConfig);
const handleSaveConfig = () => {
setServerConfig(currentConfig);
setTestResult(null);
};
const handleTestConnection = async () => {
handleSaveConfig();
setIsTesting(true);
setTestResult(null);
const ok = await api.testConnection(currentConfig);
setTestResult(ok);
setIsConnected(ok);
if (ok) {
await fetchModels();
}
setIsTesting(false);
};
return (
<SettingsSection title="SERVER CONNECTION">
<View style={styles.cardHeader}>
<View style={styles.cardHeaderTitle}>
<Network size={20} color={colors.text} style={styles.headerIcon} />
<Text style={[styles.cardTitle, { color: colors.text }]}>LM Studio API</Text>
</View>
<ConnectionBadge isConnected={isConnected} />
</View>
<View style={[styles.divider, { backgroundColor: colors.border }]} />
<LabeledInput
label="Host IP / Address"
value={host}
onChangeText={setHost}
placeholder="localhost"
autoCapitalize="none"
autoCorrect={false}
/>
<LabeledInput
label="Port Number"
value={port}
onChangeText={setPort}
placeholder="1234"
keyboardType="numeric"
/>
<LabeledInput
label="API Key (optional)"
value={apiKey}
onChangeText={setApiKey}
placeholder="Leave empty for local"
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
/>
<View style={styles.buttonRow}>
{configChanged && (
<TouchableOpacity
style={[styles.saveButton, { borderColor: colors.border, backgroundColor: colors.cardBackground }]}
onPress={handleSaveConfig}
>
<Text style={[styles.saveButtonText, { color: colors.text }]}>Save Changes</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={[
styles.testButton,
{ backgroundColor: colors.tint },
isTesting && styles.testButtonDisabled
]}
onPress={handleTestConnection}
disabled={isTesting}
>
{isTesting ? (
<ActivityIndicator size="small" color={colors.backgroundColor} />
) : (
<Text style={[styles.testButtonText, { color: colors.backgroundColor }]}>Test Connection</Text>
)}
</TouchableOpacity>
</View>
{testResult !== null && <TestResultBox ok={testResult} />}
</SettingsSection>
);
}
const styles = StyleSheet.create({
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
},
cardHeaderTitle: {
flexDirection: 'row',
alignItems: 'center',
},
headerIcon: {
marginRight: 8,
},
cardTitle: {
fontSize: 18,
fontWeight: '700',
},
divider: {
height: 1,
width: '100%',
marginBottom: 16,
},
buttonRow: {
flexDirection: 'row',
gap: 12,
marginTop: 8,
},
saveButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
borderWidth: 1,
},
saveButtonText: {
fontSize: 15,
fontWeight: '600',
},
testButton: {
flex: 2,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
},
testButtonDisabled: {
opacity: 0.7,
},
testButtonText: {
fontSize: 15,
fontWeight: '600',
},
resultBox: {
marginTop: 16,
borderRadius: 12,
padding: 14,
},
resultText: {
fontSize: 14,
textAlign: 'center',
fontWeight: '500',
},
});
+45
View File
@@ -0,0 +1,45 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import React from 'react';
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native';
interface SettingsSectionProps {
title: string;
children: React.ReactNode;
}
export function SettingsSection({ title, children }: SettingsSectionProps) {
const { width } = useWindowDimensions();
const colors = useThemeColor();
const isLargeScreen = width > 768;
return (
<View style={[styles.section, isLargeScreen && styles.desktopSection]}>
<Text style={[styles.sectionLabel, { color: colors.textMuted }]}>{title}</Text>
<View style={[styles.card, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}>
{children}
</View>
</View>
);
}
const styles = StyleSheet.create({
section: {
marginBottom: 24,
},
desktopSection: {
width: '100%',
maxWidth: 600,
},
sectionLabel: {
fontSize: 13,
fontWeight: '600',
marginLeft: 16,
marginBottom: 8,
letterSpacing: 0.5,
},
card: {
borderRadius: 16,
padding: 16,
borderWidth: 1,
},
});
-60
View File
@@ -1,60 +0,0 @@
import { StyleSheet, Text, type TextProps } from 'react-native';
import { useThemeColor } from '@/hooks/use-theme-color';
export type ThemedTextProps = TextProps & {
lightColor?: string;
darkColor?: string;
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
};
export function ThemedText({
style,
lightColor,
darkColor,
type = 'default',
...rest
}: ThemedTextProps) {
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
return (
<Text
style={[
{ color },
type === 'default' ? styles.default : undefined,
type === 'title' ? styles.title : undefined,
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
type === 'subtitle' ? styles.subtitle : undefined,
type === 'link' ? styles.link : undefined,
style,
]}
{...rest}
/>
);
}
const styles = StyleSheet.create({
default: {
fontSize: 16,
lineHeight: 24,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: '600',
},
title: {
fontSize: 32,
fontWeight: 'bold',
lineHeight: 32,
},
subtitle: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
lineHeight: 30,
fontSize: 16,
color: '#0a7ea4',
},
});
-14
View File
@@ -1,14 +0,0 @@
import { View, type ViewProps } from 'react-native';
import { useThemeColor } from '@/hooks/use-theme-color';
export type ThemedViewProps = ViewProps & {
lightColor?: string;
darkColor?: string;
};
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
}
-118
View File
@@ -1,118 +0,0 @@
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
import React from 'react';
import { ActivityIndicator, Pressable, StyleSheet, Text, TextStyle, ViewStyle } from 'react-native';
interface ButtonProps {
title: string;
onPress: () => void;
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
disabled?: boolean;
style?: ViewStyle;
textStyle?: TextStyle;
icon?: React.ReactNode;
}
export function Button({
title,
onPress,
variant = 'primary',
size = 'md',
loading = false,
disabled = false,
style,
textStyle,
icon
}: ButtonProps) {
const colorScheme = useColorScheme();
const theme = Colors[colorScheme ?? 'dark'];
const getBackgroundColor = () => {
if (disabled) return theme.surfaceHighlight;
switch (variant) {
case 'primary': return theme.primary;
case 'secondary': return theme.surfaceHighlight;
case 'outline': return 'transparent';
case 'ghost': return 'transparent';
default: return theme.primary;
}
};
const getTextColor = () => {
if (disabled) return theme.textSecondary;
switch (variant) {
case 'primary': return theme.background; // Black text on white bg
case 'secondary': return theme.text;
case 'outline': return theme.text;
case 'ghost': return theme.textSecondary;
default: return theme.background;
}
};
const getBorder = () => {
if (variant === 'outline') return { borderWidth: 1, borderColor: theme.border };
return {};
};
const getPadding = () => {
switch (size) {
case 'sm': return { paddingVertical: 8, paddingHorizontal: 12 };
case 'lg': return { paddingVertical: 16, paddingHorizontal: 24 };
default: return { paddingVertical: 12, paddingHorizontal: 20 };
}
};
const getFontSize = () => {
switch (size) {
case 'sm': return 13;
case 'lg': return 17;
default: return 15;
}
};
return (
<Pressable
onPress={onPress}
disabled={disabled || loading}
style={({ pressed }) => [
styles.button,
{ backgroundColor: getBackgroundColor() },
getBorder(),
getPadding(),
{ opacity: pressed ? 0.8 : 1 },
style
]}
>
{loading ? (
<ActivityIndicator color={getTextColor()} />
) : (
<>
{icon}
<Text style={[
styles.text,
{ color: getTextColor(), fontSize: getFontSize() },
icon ? { marginLeft: 8 } : {},
textStyle
]}>
{title}
</Text>
</>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
borderRadius: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
text: {
fontWeight: '600',
letterSpacing: 0.5,
},
});
-36
View File
@@ -1,36 +0,0 @@
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { StyleSheet, View, ViewStyle } from 'react-native';
interface GlassCardProps {
children: React.ReactNode;
style?: ViewStyle;
variant?: 'default' | 'highlight';
}
export function GlassCard({ children, style, variant = 'default' }: GlassCardProps) {
const colorScheme = useColorScheme();
const theme = Colors[colorScheme ?? 'dark'];
return (
<View style={[
styles.card,
{
backgroundColor: variant === 'default' ? theme.surface : theme.surfaceHighlight,
borderColor: theme.border,
},
style
]}>
{children}
</View>
);
}
const styles = StyleSheet.create({
card: {
borderRadius: 16,
borderWidth: 1,
padding: 16,
overflow: 'hidden',
},
});
-66
View File
@@ -1,66 +0,0 @@
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { StatusBar } from 'expo-status-bar';
import { ScrollView, StyleSheet, View, ViewStyle } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
interface ScreenWrapperProps {
children: React.ReactNode;
style?: ViewStyle;
contentContainerStyle?: ViewStyle;
scrollable?: boolean;
}
export function ScreenWrapper({
children,
style,
contentContainerStyle,
scrollable = false
}: ScreenWrapperProps) {
const colorScheme = useColorScheme();
const theme = Colors[colorScheme ?? 'dark'];
const insets = useSafeAreaInsets();
const containerStyle = [
styles.container,
{
backgroundColor: theme.background,
paddingTop: insets.top,
paddingLeft: insets.left,
paddingRight: insets.right,
},
style
];
if (scrollable) {
return (
<View style={{ flex: 1, backgroundColor: theme.background }}>
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
<ScrollView
style={containerStyle}
contentContainerStyle={[
{ paddingBottom: insets.bottom + 20 },
contentContainerStyle
]}
>
{children}
</ScrollView>
</View>
);
}
return (
<View style={containerStyle}>
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
<View style={[{ flex: 1, paddingBottom: insets.bottom }, contentContainerStyle]}>
{children}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
-45
View File
@@ -1,45 +0,0 @@
import { PropsWithChildren, useState } from 'react';
import { StyleSheet, TouchableOpacity } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false);
const theme = useColorScheme() ?? 'light';
return (
<ThemedView>
<TouchableOpacity
style={styles.heading}
onPress={() => setIsOpen((value) => !value)}
activeOpacity={0.8}>
<IconSymbol
name="chevron.right"
size={18}
weight="medium"
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
/>
<ThemedText type="defaultSemiBold">{title}</ThemedText>
</TouchableOpacity>
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
</ThemedView>
);
}
const styles = StyleSheet.create({
heading: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
content: {
marginTop: 6,
marginLeft: 24,
},
});
-32
View File
@@ -1,32 +0,0 @@
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
import { StyleProp, ViewStyle } from 'react-native';
export function IconSymbol({
name,
size = 24,
color,
style,
weight = 'regular',
}: {
name: SymbolViewProps['name'];
size?: number;
color: string;
style?: StyleProp<ViewStyle>;
weight?: SymbolWeight;
}) {
return (
<SymbolView
weight={weight}
tintColor={color}
resizeMode="scaleAspectFit"
name={name}
style={[
{
width: size,
height: size,
},
style,
]}
/>
);
}
-41
View File
@@ -1,41 +0,0 @@
// Fallback for using MaterialIcons on Android and web.
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
import { ComponentProps } from 'react';
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
type IconSymbolName = keyof typeof MAPPING;
/**
* Add your SF Symbols to Material Icons mappings here.
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
*/
const MAPPING = {
'house.fill': 'home',
'paperplane.fill': 'send',
'chevron.left.forwardslash.chevron.right': 'code',
'chevron.right': 'chevron-right',
} as IconMapping;
/**
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
* This ensures a consistent look across platforms, and optimal resource usage.
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
*/
export function IconSymbol({
name,
size = 24,
color,
style,
}: {
name: IconSymbolName;
size?: number;
color: string | OpaqueColorValue;
style?: StyleProp<TextStyle>;
weight?: SymbolWeight;
}) {
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
}
-33
View File
@@ -1,33 +0,0 @@
export type AIModel = {
id: string;
name: string;
parameters: string;
huggingFaceUrl: string;
description: string;
};
export const QWEN_MODELS: AIModel[] = [
{
id: "qwen1.5-0.5b-chat",
name: "Qwen 1.5 Chat",
parameters: "0.5B",
huggingFaceUrl: "https://huggingface.co/Qwen/Qwen1.5-0.5B-Chat",
description: "Ultra-lightweight model, extremely fast for basic tasks.",
},
{
id: "qwen1.5-1.8b-chat",
name: "Qwen 1.5 Chat",
parameters: "1.8B",
huggingFaceUrl: "https://huggingface.co/Qwen/Qwen1.5-1.8B-Chat",
description: "Good balance of speed and reasoning for mobile devices.",
},
{
id: "qwen1.5-4b-chat",
name: "Qwen 1.5 Chat",
parameters: "4B",
huggingFaceUrl: "https://huggingface.co/Qwen/Qwen1.5-4B-Chat",
description: "Highest local quality, requires more RAM and processing power.",
}
];
export const DEFAULT_MODEL = QWEN_MODELS[0];
-87
View File
@@ -1,87 +0,0 @@
/**
* ManusAI Theme - Monochromatic/Minimalist
*
* Design System:
* - Primary: White (#ffffff)
* - Background: Deep Matte Black (#09090b)
* - Surface: Zinc-900 (#18181b)
* - Border: Zinc-700 (#3f3f46)
* - Text: Zinc-50 (#fafafa)
*/
import { Platform } from 'react-native';
const ManusColors = {
// Base
black: '#09090b',
zinc900: '#18181b',
zinc800: '#27272a',
zinc700: '#3f3f46',
zinc400: '#a1a1aa',
zinc50: '#fafafa',
white: '#ffffff',
// Semantic
background: '#09090b',
surface: '#18181b',
surfaceHighlight: '#27272a',
primary: '#ffffff',
border: '#3f3f46',
text: '#fafafa',
textSecondary: '#a1a1aa',
error: '#ef4444',
};
export const Colors = {
light: {
text: ManusColors.black,
background: ManusColors.white,
tint: ManusColors.black,
icon: ManusColors.zinc700,
tabIconDefault: ManusColors.zinc400,
tabIconSelected: ManusColors.black,
// Manus Specific
surface: '#f4f4f5', // Zinc-100
surfaceHighlight: '#e4e4e7', // Zinc-200
border: '#d4d4d8', // Zinc-300
textSecondary: ManusColors.zinc700,
primary: ManusColors.black,
},
dark: {
text: ManusColors.text,
background: ManusColors.background,
tint: ManusColors.primary,
icon: ManusColors.zinc400,
tabIconDefault: ManusColors.zinc400,
tabIconSelected: ManusColors.primary,
// Manus Specific
surface: ManusColors.surface,
surfaceHighlight: ManusColors.surfaceHighlight,
border: ManusColors.border,
textSecondary: ManusColors.textSecondary,
primary: ManusColors.primary,
},
// Unified access for components that ignore system theme
manus: ManusColors,
};
export const Fonts = Platform.select({
ios: {
sans: 'system-ui',
serif: 'ui-serif',
rounded: 'ui-rounded',
mono: 'ui-monospace',
},
android: {
sans: 'sans-serif',
serif: 'serif',
rounded: 'sans-serif-medium',
mono: 'monospace',
},
default: {
sans: 'system-ui',
serif: 'serif',
rounded: 'system-ui',
mono: 'monospace',
},
});
-1
View File
@@ -1 +0,0 @@
export { useColorScheme } from 'react-native';
-21
View File
@@ -1,21 +0,0 @@
import { useEffect, useState } from 'react';
import { useColorScheme as useRNColorScheme } from 'react-native';
/**
* To support static rendering, this value needs to be re-calculated on the client side for web
*/
export function useColorScheme() {
const [hasHydrated, setHasHydrated] = useState(false);
useEffect(() => {
setHasHydrated(true);
}, []);
const colorScheme = useRNColorScheme();
if (hasHydrated) {
return colorScheme;
}
return 'light';
}
-21
View File
@@ -1,21 +0,0 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme() ?? 'light';
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}
+23
View File
@@ -0,0 +1,23 @@
import { useEffect } from 'react';
import { useAppStore } from '../store/useAppStore';
import { useLMStudio } from './useLMStudio';
/**
* Checks the server once when the app opens, after the saved settings have been
* restored, so the connection badge and model list are not stale until the user
* visits Settings.
*/
export function useAutoConnect() {
const { fetchModels } = useLMStudio();
useEffect(() => {
const { persist } = useAppStore;
if (persist.hasHydrated()) {
void fetchModels();
return;
}
return persist.onFinishHydration(() => void fetchModels());
// Mount only: later checks are triggered by the user (model selector, Settings).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}
+55 -97
View File
@@ -2,7 +2,16 @@ import { useCallback, useRef } from 'react';
import { SYSTEM_PROMPT } from '../constants/SystemPrompt';
import * as api from '../services/lmStudioApi';
import { ChatMessage as ApiMessage } from '../services/types';
import { useAppStore } from '../store/useAppStore';
import { ChatMessage, useAppStore } from '../store/useAppStore';
const NO_MODEL_MESSAGE = '⚠️ No model selected. Please select a model first.';
function buildApiMessages(messages: ChatMessage[]): ApiMessage[] {
return [
{ role: 'system', content: SYSTEM_PROMPT },
...messages.map((m) => ({ role: m.role, content: m.content })),
];
}
export function useLMStudio() {
const serverConfig = useAppStore((s) => s.serverConfig);
@@ -44,95 +53,16 @@ export function useLMStudio() {
}
}, [activeModelId, setAvailableModels, setIsConnected, setActiveModelId]);
const sendMessage = useCallback(async (text: string) => {
if (isStreaming) return;
addMessage({ role: 'user', content: text });
const currentMessages = useAppStore.getState().messages;
const apiMessages: ApiMessage[] = [
{ role: 'system', content: SYSTEM_PROMPT },
...currentMessages.map((m) => ({
role: m.role,
content: m.content,
}))
];
const modelId = useAppStore.getState().activeModelId;
if (!modelId) {
addMessage({
role: 'assistant',
content: '⚠️ No model selected. Please select a model first.',
});
return;
}
// Adds an empty assistant message and streams the model's answer into it.
const streamAnswer = useCallback((modelId: string, apiMessages: ApiMessage[]) => {
const assistantId = addMessage({ role: 'assistant', content: '' });
setIsStreaming(true);
try {
abortRef.current = api.chatCompletionStream(
serverConfig,
modelId,
apiMessages,
(chunk) => {
appendToMessage(assistantId, chunk);
},
() => {
setIsStreaming(false);
abortRef.current = null;
useAppStore.getState().saveCurrentChat();
},
(error) => {
const errorMsg = `\n\n⚠️ Error: ${error.message}`;
appendToMessage(assistantId, errorMsg);
setIsStreaming(false);
abortRef.current = null;
useAppStore.getState().saveCurrentChat();
},
);
} catch (err) {
const errorMsg = `⚠️ Connection failed: ${(err as Error).message}`;
appendToMessage(assistantId, errorMsg);
const finish = () => {
setIsStreaming(false);
abortRef.current = null;
useAppStore.getState().saveCurrentChat();
}
}, [isStreaming, serverConfig, addMessage, appendToMessage, setIsStreaming]);
const regenerateResponse = useCallback(async () => {
if (isStreaming) return;
const msgs = useAppStore.getState().messages;
// Find the last user message
let lastUserIdx = -1;
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === 'user') {
lastUserIdx = i;
break;
}
}
if (lastUserIdx === -1) return;
const lastUserMsg = msgs[lastUserIdx];
// Remove messages after the last user message
const store = useAppStore.getState();
store.deleteMessagesAfter(lastUserMsg.id);
// Build API messages from remaining
const remaining = useAppStore.getState().messages;
const apiMessages: ApiMessage[] = [
{ role: 'system', content: SYSTEM_PROMPT },
...remaining.map((m) => ({
role: m.role,
content: m.content,
}))
];
const modelId = useAppStore.getState().activeModelId;
if (!modelId) return;
const assistantId = addMessage({ role: 'assistant', content: '' });
setIsStreaming(true);
};
try {
abortRef.current = api.chatCompletionStream(
@@ -140,30 +70,58 @@ export function useLMStudio() {
modelId,
apiMessages,
(chunk) => appendToMessage(assistantId, chunk),
() => {
setIsStreaming(false);
abortRef.current = null;
useAppStore.getState().saveCurrentChat();
},
finish,
(error) => {
appendToMessage(assistantId, `\n\n⚠️ Error: ${error.message}`);
setIsStreaming(false);
abortRef.current = null;
useAppStore.getState().saveCurrentChat();
appendToMessage(assistantId, `
⚠️ Error: ${error.message}`);
finish();
},
);
} catch (err) {
appendToMessage(assistantId, `⚠️ Connection failed: ${(err as Error).message}`);
setIsStreaming(false);
useAppStore.getState().saveCurrentChat();
finish();
}
}, [isStreaming, serverConfig, addMessage, appendToMessage, setIsStreaming]);
}, [serverConfig, addMessage, appendToMessage, setIsStreaming]);
const sendMessage = useCallback(async (text: string) => {
if (isStreaming) return;
addMessage({ role: 'user', content: text });
const modelId = useAppStore.getState().activeModelId;
if (!modelId) {
addMessage({ role: 'assistant', content: NO_MODEL_MESSAGE });
return;
}
streamAnswer(modelId, buildApiMessages(useAppStore.getState().messages));
}, [isStreaming, addMessage, streamAnswer]);
const regenerateResponse = useCallback(async () => {
if (isStreaming) return;
const { messages: msgs, activeModelId: modelId, deleteMessagesAfter } = useAppStore.getState();
const lastUserMsg = [...msgs].reverse().find((m) => m.role === 'user');
if (!lastUserMsg) return;
// Checked before deleting anything, so the current answer is not lost for nothing.
if (!modelId) {
addMessage({ role: 'assistant', content: NO_MODEL_MESSAGE });
return;
}
// Drop the previous answer(s) and ask again from the last user message.
deleteMessagesAfter(lastUserMsg.id);
streamAnswer(modelId, buildApiMessages(useAppStore.getState().messages));
}, [isStreaming, addMessage, streamAnswer]);
const stopStreaming = useCallback(() => {
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
setIsStreaming(false);
useAppStore.getState().saveCurrentChat();
}
}, [setIsStreaming]);
+13
View File
@@ -0,0 +1,13 @@
module.exports = {
preset: 'jest-expo',
setupFiles: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
// jest-expo's default pattern plus the ESM-only markdown renderer.
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|@ronradtke/.*)',
],
testMatch: ['<rootDir>/__tests__/**/*.test.{ts,tsx}'],
testPathIgnorePatterns: ['/node_modules/', '/dist/', '/.agent/'],
};
+3
View File
@@ -0,0 +1,3 @@
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
);
+3363 -813
View File
File diff suppressed because it is too large. Load diff
+17 -14
View File
@@ -4,14 +4,15 @@
"version": "1.0.0",
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "expo lint"
"lint": "expo lint",
"typecheck": "tsc --noEmit",
"test": "jest",
"build": "expo export --platform web"
},
"dependencies": {
"@babel/plugin-transform-runtime": "^7.29.0",
"@babel/runtime": "^7.28.6",
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -19,29 +20,25 @@
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@ronradtke/react-native-markdown-display": "^8.1.0",
"expo": "~54.0.31",
"expo-clipboard": "^55.0.8",
"expo-constants": "~18.0.13",
"expo-font": "~14.0.10",
"expo": "~54.0.37",
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.14",
"expo-font": "~14.0.12",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.21",
"expo-linking": "~8.0.12",
"expo-router": "~6.0.24",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10",
"immer": "^11.1.4",
"lucide-react-native": "^0.576.0",
"react": "^19.1.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-sse": "^1.2.1",
"react-native-svg": "15.12.1",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1",
@@ -49,9 +46,15 @@
"zustand": "^5.0.11"
},
"devDependencies": {
"@testing-library/react-native": "^13.3.3",
"@types/jest": "^29.5.14",
"@types/react": "~19.1.0",
"babel-preset-expo": "~54.0.12",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"jest": "^29.7.0",
"jest-expo": "~54.0.0",
"react-test-renderer": "^19.1.0",
"typescript": "~5.9.2"
},
"private": true
-112
View File
@@ -1,112 +0,0 @@
#!/usr/bin/env node
/**
* This script is used to reset the project to a blank state.
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
*/
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const root = process.cwd();
const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
const exampleDir = "app-example";
const newAppDir = "app";
const exampleDirPath = path.join(root, exampleDir);
const indexContent = `import { Text, View } from "react-native";
export default function Index() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Text>Edit app/index.tsx to edit this screen.</Text>
</View>
);
}
`;
const layoutContent = `import { Stack } from "expo-router";
export default function RootLayout() {
return <Stack />;
}
`;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const moveDirectories = async (userInput) => {
try {
if (userInput === "y") {
// Create the app-example directory
await fs.promises.mkdir(exampleDirPath, { recursive: true });
console.log(`📁 /${exampleDir} directory created.`);
}
// Move old directories to new app-example directory or delete them
for (const dir of oldDirs) {
const oldDirPath = path.join(root, dir);
if (fs.existsSync(oldDirPath)) {
if (userInput === "y") {
const newDirPath = path.join(root, exampleDir, dir);
await fs.promises.rename(oldDirPath, newDirPath);
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
} else {
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
console.log(`❌ /${dir} deleted.`);
}
} else {
console.log(`➡️ /${dir} does not exist, skipping.`);
}
}
// Create new /app directory
const newAppDirPath = path.join(root, newAppDir);
await fs.promises.mkdir(newAppDirPath, { recursive: true });
console.log("\n📁 New /app directory created.");
// Create index.tsx
const indexPath = path.join(newAppDirPath, "index.tsx");
await fs.promises.writeFile(indexPath, indexContent);
console.log("📄 app/index.tsx created.");
// Create _layout.tsx
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
await fs.promises.writeFile(layoutPath, layoutContent);
console.log("📄 app/_layout.tsx created.");
console.log("\n✅ Project reset complete. Next steps:");
console.log(
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
userInput === "y"
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
: ""
}`
);
} catch (error) {
console.error(`❌ Error during script execution: ${error.message}`);
}
};
rl.question(
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
(answer) => {
const userInput = answer.trim().toLowerCase() || "y";
if (userInput === "y" || userInput === "n") {
moveDirectories(userInput).finally(() => rl.close());
} else {
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
rl.close();
}
}
);
+39 -21
View File
@@ -9,12 +9,18 @@ import {
StreamChunk,
} from './types';
function buildBaseUrl(config: ServerConfig): string {
export function buildBaseUrl(config: ServerConfig): string {
const host = config.host.replace(/\/+$/, '');
const protocol = host.startsWith('http') ? '' : 'http://';
const protocol = /^https?:\/\//i.test(host) ? '' : 'http://';
return `${protocol}${host}:${config.port}/v1`;
}
function connectionError(baseUrl: string): Error {
return new Error(
`Could not reach the server at ${baseUrl}. Check the host and port in Settings and that LM Studio's local server is running.`,
);
}
function buildHeaders(config: ServerConfig): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
@@ -26,19 +32,19 @@ function buildHeaders(config: ServerConfig): Record<string, string> {
}
export async function testConnection(config: ServerConfig): Promise<boolean> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(`${buildBaseUrl(config)}/models`, {
headers: buildHeaders(config),
signal: controller.signal,
});
clearTimeout(timeout);
return res.ok;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
@@ -84,7 +90,7 @@ export async function chatCompletion(
}
// Parse SSE lines from a text buffer
function parseSSELines(
export function parseSSELines(
buffer: string,
onChunk: (content: string) => void,
onDone: () => void,
@@ -95,9 +101,9 @@ function parseSSELines(
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith('data: ')) continue;
if (!trimmed.startsWith('data:')) continue;
const payload = trimmed.slice(6);
const payload = trimmed.slice(5).trim();
if (payload === '[DONE]') {
isDone = true;
onDone();
@@ -123,6 +129,7 @@ function streamWithXHR(
url: string,
headers: Record<string, string>,
body: string,
baseUrl: string,
onChunk: (content: string) => void,
onDone: () => void,
onError: (error: Error) => void,
@@ -146,6 +153,10 @@ function streamWithXHR(
};
xhr.onload = () => {
if (xhr.status < 200 || xhr.status >= 300) {
onError(new Error(`Stream failed: ${xhr.status} ${xhr.statusText}`));
return;
}
// Process any remaining buffer
if (buffer.trim()) {
parseSSELines(buffer + '\n', onChunk, onDone);
@@ -154,7 +165,7 @@ function streamWithXHR(
};
xhr.onerror = () => {
onError(new Error(`XHR streaming failed: ${xhr.status} ${xhr.statusText}`));
onError(connectionError(baseUrl));
};
xhr.ontimeout = () => {
@@ -175,17 +186,23 @@ async function streamWithFetch(
url: string,
headers: Record<string, string>,
body: string,
baseUrl: string,
onChunk: (content: string) => void,
onDone: () => void,
onError: (error: Error) => void,
signal: AbortSignal,
): Promise<void> {
const res = await fetch(url, {
method: 'POST',
headers,
body,
signal,
});
let res: Response;
try {
res = await fetch(url, {
method: 'POST',
headers,
body,
signal,
});
} catch (err) {
if ((err as Error).name === 'AbortError') throw err;
throw connectionError(baseUrl);
}
if (!res.ok) {
throw new Error(`Stream failed: ${res.status} ${res.statusText}`);
@@ -229,7 +246,8 @@ export function chatCompletionStream(
...options,
};
const url = `${buildBaseUrl(config)}/chat/completions`;
const baseUrl = buildBaseUrl(config);
const url = `${baseUrl}/chat/completions`;
const headers = buildHeaders(config);
const bodyStr = JSON.stringify(body);
@@ -245,7 +263,7 @@ export function chatCompletionStream(
// Web: use fetch + ReadableStream
const controller = new AbortController();
streamWithFetch(url, headers, bodyStr, onChunk, safeOnDone, onError, controller.signal)
streamWithFetch(url, headers, bodyStr, baseUrl, onChunk, safeOnDone, controller.signal)
.catch((err) => {
if ((err as Error).name !== 'AbortError') {
onError(err as Error);
@@ -255,6 +273,6 @@ export function chatCompletionStream(
return { abort: () => controller.abort() };
} else {
// Native: use XMLHttpRequest
return streamWithXHR(url, headers, bodyStr, onChunk, safeOnDone, onError);
return streamWithXHR(url, headers, bodyStr, baseUrl, onChunk, safeOnDone, onError);
}
}
+54
View File
@@ -0,0 +1,54 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { AppState } from 'react-native';
import type { StateStorage } from 'zustand/middleware';
export interface DebouncedStorage extends StateStorage {
/** Writes every pending value right away. */
flush: () => Promise<void>;
}
/**
* Wraps a storage so bursts of writes collapse into one trailing write.
*
* The chat store is persisted on every state change and a streamed answer
* changes it once per token, which would serialize every saved session and hit
* AsyncStorage dozens of times per second.
*/
export function createDebouncedStorage(base: StateStorage, delayMs = 400): DebouncedStorage {
const pending = new Map<string, string>();
let timer: ReturnType<typeof setTimeout> | null = null;
const flush = async () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
const entries = [...pending.entries()];
pending.clear();
await Promise.all(entries.map(([name, value]) => base.setItem(name, value)));
};
return {
getItem: (name) => {
const value = pending.get(name);
return value === undefined ? base.getItem(name) : Promise.resolve(value);
},
setItem: (name, value) => {
pending.set(name, value);
if (timer) clearTimeout(timer);
timer = setTimeout(flush, delayMs);
},
removeItem: (name) => {
pending.delete(name);
return base.removeItem(name);
},
flush,
};
}
export const appStorage = createDebouncedStorage(AsyncStorage);
// Do not lose the last tokens when the app is sent to the background or closed.
AppState.addEventListener('change', (state) => {
if (state !== 'active') void appStorage.flush();
});
+3 -2
View File
@@ -1,7 +1,7 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import { DEFAULT_SERVER_CONFIG, LMModel, ServerConfig } from '../services/types';
import { appStorage } from './debouncedStorage';
export interface ChatMessage {
id: string;
@@ -164,10 +164,11 @@ export const useAppStore = create<AppState>()(
}),
{
name: 'localaiapp-storage',
storage: createJSONStorage(() => AsyncStorage),
storage: createJSONStorage(() => appStorage),
partialize: (state) => ({
theme: state.theme,
serverConfig: state.serverConfig,
activeModelId: state.activeModelId,
chatSessions: state.chatSessions,
messages: state.messages,
currentChatId: state.currentChatId,
+16
View File
@@ -0,0 +1,16 @@
import type { ChatMessage } from '../store/useAppStore';
/** Id of the most recent assistant message, or null when there is none. */
export function findLastAssistantId(messages: ChatMessage[]): string | null {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'assistant') return messages[i].id;
}
return null;
}
/** True while the model has been asked to answer but has not produced any text yet. */
export function shouldShowTypingIndicator(messages: ChatMessage[], isStreaming: boolean): boolean {
if (!isStreaming) return false;
const last = messages[messages.length - 1];
return last?.role === 'assistant' && last.content === '';
}
+25
View File
@@ -0,0 +1,25 @@
import { DEFAULT_SERVER_CONFIG, ServerConfig } from '../services/types';
export interface ServerConfigForm {
host: string;
port: string;
apiKey: string;
}
/** Turns the raw text fields of the settings form into a usable ServerConfig. */
export function buildServerConfig(form: ServerConfigForm): ServerConfig {
return {
host: form.host.trim() || DEFAULT_SERVER_CONFIG.host,
port: parseInt(form.port, 10) || DEFAULT_SERVER_CONFIG.port,
apiKey: form.apiKey.trim() || undefined,
};
}
/** True when the form differs from what is currently stored. */
export function isConfigChanged(form: ServerConfigForm, saved: ServerConfig): boolean {
return (
form.host !== saved.host ||
form.port !== saved.port.toString() ||
form.apiKey !== (saved.apiKey ?? '')
);
}
+7
View File
@@ -0,0 +1,7 @@
/** Whether the dark theme is in effect for a stored preference and the OS scheme. */
export function isDarkTheme(
preference: 'light' | 'dark' | 'system',
systemScheme: 'light' | 'dark',
): boolean {
return preference === 'dark' || (preference === 'system' && systemScheme === 'dark');
}