Skip to content

Commit 3870b76

Browse files
feat(ui): app-wide UI refresh (#208)
## Summary - Home screen auto-redirects into a phantom chat with the last-used model; drawer/header **New chat** skip the home screen entirely via `startPhantomChat` - Empty-state gradient (`softPrimary → main`) on the chat screen, plus a **What's new** card and prompt suggestions on phantom chats - Streaming markdown powered by `react-native-streamdown` (Bundle Mode wired up in `babel.config.js` + `metro.config.js`) for the in-flight assistant message and the thinking block - Model hub: group by family with Recommended / Experimental / Mine tabs, per-family descriptions, storage footer with **Clear all**, red trash button on downloaded variants inside the family modal - Strip ` - Quantized` suffix from built-in names with a DB migration to preserve downloaded state; extend compatibility heuristic to check `modelPath` - Phantom chat title opens the model sheet with a chevron affordance; in-chat model switch persists to `lastUsedModelId` - Designer-gray chat bar / prompt cards on light theme via new `cardSurface` + `chatBar` + `attachButton` tokens; consistent voice-mode tint across themes ## Bug fixes - Fixed race where clearing `activeChatId` during navigation retriggered the previous chat's `useFocusEffect`, flashing stale messages on the new phantom chat - Fixed chat-bar baseline height being captured during the empty state, which later squeezed the scroll view once the suggestions block disappeared - Models deleted in the app are now removed from disk via `ExpoResourceFetcher.deleteResources` instead of relying on in-memory path tracking ## Test plan - [ ] Light + dark: home → chat auto-redirect, composer ready with last-used model - [ ] Drawer / header **New chat** from an existing chat → no flash, lands on phantom chat - [ ] Streaming animation on assistant messages and thinking block (requires full rebuild after Bundle Mode changes) - [ ] Model hub: family drill-in, trash button deletes files, Clear all flow, tab switch preserves scroll - [ ] Fresh install and upgrade install both seed defaults correctly (DB migration) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b3c3f96 commit 3870b76

55 files changed

Lines changed: 854 additions & 125655 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

__tests__/ModelCard.test.tsx

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,24 @@ const mockUseModelStore = useModelStore as jest.Mock;
5050
const mockIsModelCompatible = isModelCompatible as jest.Mock;
5151
const mockNetInfoFetch = NetInfo.fetch as jest.Mock;
5252

53-
const baseModel = {
53+
const baseModel: {
54+
id: number;
55+
modelName: string;
56+
source: 'remote';
57+
isDownloaded: boolean;
58+
featured: boolean;
59+
parameters: number;
60+
modelSize: number;
61+
modelPath: string;
62+
tokenizerPath: string;
63+
tokenizerConfigPath: string;
64+
thinking: boolean;
65+
vision?: boolean;
66+
labels: string[];
67+
} = {
5468
id: 1,
5569
modelName: 'Llama-3B',
56-
source: 'remote' as const,
70+
source: 'remote',
5771
isDownloaded: false,
5872
featured: false,
5973
parameters: 3.21,
@@ -103,25 +117,31 @@ describe('display', () => {
103117
expect(screen.getByTestId('chip-2.50 GB')).toBeTruthy();
104118
});
105119

106-
it('shows Featured chip when model is featured and compactView is false', () => {
107-
renderCard({ featured: true, compactView: false });
108-
expect(screen.getByTestId('chip-Featured')).toBeTruthy();
120+
it('shows Incompatible chip when model is not compatible', () => {
121+
mockIsModelCompatible.mockReturnValue(false);
122+
renderCard();
123+
expect(screen.getByTestId('chip-Incompatible')).toBeTruthy();
109124
});
110125

111-
it('does not show Featured chip in compact view even when featured', () => {
112-
renderCard({ featured: true });
113-
expect(screen.queryByTestId('chip-Featured')).toBeNull();
126+
it('shows Vision chip when model supports vision', () => {
127+
renderCard({ vision: true });
128+
expect(screen.getByTestId('chip-Vision')).toBeTruthy();
114129
});
115130

116-
it('does not show Featured chip for non-featured model', () => {
117-
renderCard({ featured: false, compactView: false });
118-
expect(screen.queryByTestId('chip-Featured')).toBeNull();
131+
it('does not show Vision chip when model does not support vision', () => {
132+
renderCard({ vision: false });
133+
expect(screen.queryByTestId('chip-Vision')).toBeNull();
119134
});
120135

121-
it('shows Incompatible chip when model is not compatible', () => {
122-
mockIsModelCompatible.mockReturnValue(false);
123-
renderCard();
124-
expect(screen.getByTestId('chip-Incompatible')).toBeTruthy();
136+
it('renders label chips from model.labels', () => {
137+
renderCard({ compactView: false, labels: ['Fast', 'Reasoning'] });
138+
expect(screen.getByTestId('chip-Fast')).toBeTruthy();
139+
expect(screen.getByTestId('chip-Reasoning')).toBeTruthy();
140+
});
141+
142+
it('omits label chips in compact view', () => {
143+
renderCard({ compactView: true, labels: ['Fast'] });
144+
expect(screen.queryByTestId('chip-Fast')).toBeNull();
125145
});
126146

127147
it('calls onPress when card is tapped', () => {

__tests__/ModelHubTabs.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jest.mock('../context/ThemeContext', () => ({
1414
describe('ModelHubTabs', () => {
1515
it('renders all three tabs', () => {
1616
render(<ModelHubTabs value="featured" onChange={jest.fn()} />);
17-
expect(screen.getByText('Featured')).toBeTruthy();
17+
expect(screen.getByText('Recommended')).toBeTruthy();
1818
expect(screen.getByText('Experimental')).toBeTruthy();
1919
expect(screen.getByText('Mine')).toBeTruthy();
2020
});
Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import React from 'react';
2-
import { render, screen, fireEvent } from '@testing-library/react-native';
2+
import { render, fireEvent } from '@testing-library/react-native';
33

44
jest.mock('../context/ThemeContext', () => ({
55
useTheme: () => ({
66
theme: { ...require('../styles/colors').lightTheme },
77
}),
88
}));
99

10-
const mockPush = jest.fn();
11-
jest.mock('expo-router', () => ({
12-
useRouter: () => ({ push: mockPush }),
10+
jest.mock('expo-sqlite', () => ({
11+
useSQLiteContext: () => ({}),
12+
}));
13+
14+
const mockStart = jest.fn();
15+
jest.mock('../utils/startPhantomChat', () => ({
16+
startPhantomChat: (...args: unknown[]) => mockStart(...args),
1317
}));
1418

1519
import NewChatHeaderButton from '../components/NewChatHeaderButton';
@@ -24,18 +28,17 @@ describe('NewChatHeaderButton', () => {
2428
expect(UNSAFE_getAllByType(TouchableOpacity).length).toBeGreaterThan(0);
2529
});
2630

27-
it('calls router.push("/") when noOp is not set', () => {
28-
render(<NewChatHeaderButton />);
29-
const { TouchableOpacity } = require('react-native');
31+
it('starts a phantom chat when noOp is not set', () => {
3032
const { UNSAFE_getByType } = render(<NewChatHeaderButton />);
33+
const { TouchableOpacity } = require('react-native');
3134
fireEvent.press(UNSAFE_getByType(TouchableOpacity));
32-
expect(mockPush).toHaveBeenCalledWith('/');
35+
expect(mockStart).toHaveBeenCalled();
3336
});
3437

35-
it('does not call router.push when noOp=true', () => {
38+
it('does nothing when noOp=true', () => {
3639
const { UNSAFE_getByType } = render(<NewChatHeaderButton noOp={true} />);
3740
const { TouchableOpacity } = require('react-native');
3841
fireEvent.press(UNSAFE_getByType(TouchableOpacity));
39-
expect(mockPush).not.toHaveBeenCalled();
42+
expect(mockStart).not.toHaveBeenCalled();
4043
});
4144
});

android/app/build.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,8 @@ android {
100100
applicationId 'com.swmansion.privatemind'
101101
minSdkVersion rootProject.ext.minSdkVersion
102102
targetSdkVersion rootProject.ext.targetSdkVersion
103-
versionCode 61
104-
versionName "1.1.5"
103+
versionCode 62
104+
versionName "1.1.6"
105105

106106
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
107107
}

app.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"scheme": "private-mind",
44
"name": "Private Mind",
55
"slug": "private-mind",
6-
"version": "1.1.5",
6+
"version": "1.1.6",
77
"orientation": "portrait",
88
"icon": "./assets/icons/icon.png",
99
"userInterfaceStyle": "automatic",
@@ -52,9 +52,7 @@
5252
"iosBackgroundMode": false,
5353
"iosMicrophonePermission": "This app requires access to the microphone to record audio.",
5454
"androidForegroundService": false,
55-
"androidPermissions": [
56-
"android.permission.RECORD_AUDIO"
57-
]
55+
"androidPermissions": ["android.permission.RECORD_AUDIO"]
5856
}
5957
],
6058
"expo-asset",

app/(drawer)/chat/[id].tsx

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useCallback } from 'react';
1+
import React, { useCallback, useRef } from 'react';
22
import { useFocusEffect, useLocalSearchParams } from 'expo-router';
33
import ChatScreen from '../../../components/chat-screen/ChatScreen';
44
import { useState } from 'react';
@@ -24,9 +24,10 @@ function ChatScreenInner() {
2424
const { modelId }: { modelId: string } = useLocalSearchParams();
2525
const { activeChatMessages, activeChatId, setActiveChatId } = useLLMStore();
2626
const { getModelById } = useModelStore();
27-
const { getChatById, setChatModel, loadChats } = useChatStore();
27+
const { getChatById, setChatModel, loadChats, phantomChat } = useChatStore();
2828
const chatId = parseInt(rawId);
2929
const chat = getChatById(chatId);
30+
const isPhantom = phantomChat?.id === chatId;
3031
const resolvedModelId = modelId ?? chat?.modelId;
3132
const resolvedModel = resolvedModelId
3233
? getModelById(parseInt(resolvedModelId.toString()))
@@ -37,27 +38,42 @@ function ChatScreenInner() {
3738
// from a bottom sheet), and flipping messageHistory to [] mid-session
3839
// causes Messages.tsx to reset its reveal animation, briefly blanking
3940
// the chat. If the store already has this chat active, skip the
40-
// reset and use the existing data.
41-
const [isLoading, setIsLoading] = useState(activeChatId !== chatId);
41+
// reset and use the existing data. Phantom chats have no history, so
42+
// skip loading entirely.
43+
const [isLoading, setIsLoading] = useState(
44+
!isPhantom && activeChatId !== chatId
45+
);
46+
47+
const isEmpty = !isLoading && activeChatMessages.length === 0;
48+
const openModelSheetRef = useRef<(() => void) | null>(null);
4249

4350
const { MenuElements } = useChatHeader({
4451
chatId: chatId,
4552
chatModel: model,
53+
isEmpty,
54+
onSelectModelFromTitle: isPhantom
55+
? () => openModelSheetRef.current?.()
56+
: undefined,
4657
});
4758

4859
useFocusEffect(
4960
useCallback(() => {
50-
if (activeChatId === chatId) {
61+
// Read activeChatId via store to avoid re-firing this effect when the
62+
// store's activeChatId changes while the screen is focused — otherwise
63+
// clearing activeChatId (e.g. from startPhantomChat during navigation)
64+
// would retrigger an unwanted re-fetch on the previously-focused chat.
65+
const currentActiveId = useLLMStore.getState().activeChatId;
66+
if (currentActiveId === chatId) {
5167
return;
5268
}
5369
const initChat = async () => {
54-
setIsLoading(true);
70+
if (!isPhantom) setIsLoading(true);
5571
await setActiveChatId(chatId);
5672
setIsLoading(false);
5773
};
5874

5975
initChat();
60-
}, [chatId, activeChatId])
76+
}, [chatId, isPhantom])
6177
);
6278

6379
const handleSetModel = async (model: Model) => {
@@ -75,6 +91,7 @@ function ChatScreenInner() {
7591
isLoading={isLoading}
7692
model={model}
7793
selectModel={handleSetModel}
94+
openModelSheetRef={openModelSheetRef}
7895
/>
7996
{MenuElements}
8097
</>

0 commit comments

Comments
 (0)