Compare commits
5 Commits
da373199e0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7494107491 | ||
|
|
d77bdcf80e | ||
|
|
21b669f96c | ||
|
|
a213b1593a | ||
|
|
41c7634b75 |
@@ -17,6 +17,7 @@ import { ThemeProvider, useTheme } from './src/theme/ThemeProvider';
|
||||
import { loadAgents } from './src/store/agentsSlice';
|
||||
import { rehydrateChat } from './src/store/chatSlice';
|
||||
import { loadChatState } from './src/store/persistence';
|
||||
import { loadSettings } from './src/store/settingsSlice';
|
||||
|
||||
function InnerApp() {
|
||||
const { scheme, colors } = useTheme();
|
||||
@@ -30,6 +31,7 @@ function InnerApp() {
|
||||
const [persisted] = await Promise.all([
|
||||
loadChatState(),
|
||||
dispatch(loadAgents()),
|
||||
dispatch(loadSettings()),
|
||||
]);
|
||||
if (persisted) {
|
||||
dispatch(rehydrateChat(persisted));
|
||||
|
||||
@@ -84,6 +84,7 @@ android {
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
}
|
||||
signingConfigs {
|
||||
debug {
|
||||
@@ -110,6 +111,7 @@ android {
|
||||
dependencies {
|
||||
// The version of react-native is set by the React Native Gradle Plugin
|
||||
implementation("com.facebook.react:react-android")
|
||||
androidTestImplementation('com.wix:detox:+')
|
||||
|
||||
if (hermesEnabled.toBoolean()) {
|
||||
implementation("com.facebook.react:hermes-android")
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.mymobileagent;
|
||||
|
||||
import com.wix.detox.Detox;
|
||||
import com.wix.detox.config.DetoxConfig;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.filters.LargeTest;
|
||||
import androidx.test.rule.ActivityTestRule;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
@LargeTest
|
||||
public class DetoxTest {
|
||||
|
||||
@Rule
|
||||
public ActivityTestRule<MainActivity> mActivityRule =
|
||||
new ActivityTestRule<>(MainActivity.class, false, false);
|
||||
|
||||
@Test
|
||||
public void runDetoxTests() {
|
||||
DetoxConfig detoxConfig = new DetoxConfig();
|
||||
detoxConfig.idlePolicyConfig.masterTimeoutSec = 90;
|
||||
detoxConfig.idlePolicyConfig.idleResourceTimeoutSec = 60;
|
||||
detoxConfig.rnContextLoadTimeoutSec = (BuildConfig.DEBUG ? 180 : 60);
|
||||
|
||||
Detox.runTests(mActivityRule, detoxConfig);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:allowBackup="false"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">10.0.2.2</domain>
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -19,3 +19,12 @@ buildscript {
|
||||
}
|
||||
|
||||
apply plugin: "com.facebook.react.rootproject"
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
maven {
|
||||
url("$rootDir/../node_modules/detox/Detox-android")
|
||||
}
|
||||
maven { url 'https://www.jitpack.io' }
|
||||
}
|
||||
}
|
||||
|
||||
48
app/detox.config.js
Normal file
48
app/detox.config.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Detox configuration with both attached-device and emulator options.
|
||||
* A helper script (e2e/run-detox.js) will pick the attached device if present,
|
||||
* otherwise fall back to the emulator configuration.
|
||||
*/
|
||||
module.exports = {
|
||||
testRunner: {
|
||||
$0: 'jest',
|
||||
args: {
|
||||
config: 'e2e/jest.config.js',
|
||||
_: ['e2e']
|
||||
}
|
||||
},
|
||||
devices: {
|
||||
'android.attached': {
|
||||
type: 'android.attached',
|
||||
device: {
|
||||
adbName: '.*' // match any attached device
|
||||
}
|
||||
},
|
||||
'android.emulator': {
|
||||
type: 'android.emulator',
|
||||
device: {
|
||||
avdName: 'Pixel_3a_API_30_x86'
|
||||
}
|
||||
}
|
||||
},
|
||||
apps: {
|
||||
'android.debug': {
|
||||
type: 'android.apk',
|
||||
binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
|
||||
build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug && cd ..',
|
||||
testBinaryPath: 'android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk',
|
||||
reversePorts: [8081],
|
||||
launchTimeout: 120000 // wait up to 2 min for the app to connect (Metro must be running)
|
||||
}
|
||||
},
|
||||
configurations: {
|
||||
'android.attached+android.debug': {
|
||||
device: 'android.attached',
|
||||
app: 'android.debug'
|
||||
},
|
||||
'android.emu.debug+android.debug': {
|
||||
device: 'android.emulator',
|
||||
app: 'android.debug'
|
||||
}
|
||||
}
|
||||
};
|
||||
109
app/e2e/agents.e2e.js
Normal file
109
app/e2e/agents.e2e.js
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* e2e/agents.e2e.js
|
||||
*
|
||||
* Verifies the Agents screen:
|
||||
* - screen renders correctly
|
||||
* - new agent can be created
|
||||
* - created agent appears in the list
|
||||
* - agent editor can be reopened (edit flow)
|
||||
* - agent can be deleted via the delete button
|
||||
*/
|
||||
const { navigatePastSplash, tapTab } = require('./helpers');
|
||||
|
||||
const TEST_AGENT_NAME = 'E2E Test Agent';
|
||||
const TEST_AGENT_ID = 'E2E-Test-Agent'; // name with spaces replaced by dashes
|
||||
|
||||
describe('Agents Screen', () => {
|
||||
beforeAll(async () => {
|
||||
await device.launchApp({ newInstance: true });
|
||||
await navigatePastSplash();
|
||||
await tapTab('Agents');
|
||||
// Use testID to avoid matching the bottom tab bar "Agents" label
|
||||
await waitFor(element(by.id('agents-screen-title')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000);
|
||||
});
|
||||
|
||||
// ─── Render ──────────────────────────────────────────────────────────────
|
||||
|
||||
it('shows the Agents screen header', async () => {
|
||||
await expect(element(by.id('agents-screen-title'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows the Nouvel agent button', async () => {
|
||||
await expect(element(by.id('new-agent-btn'))).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Create agent ─────────────────────────────────────────────────────────
|
||||
|
||||
it('opens the agent editor when tapping Nouvel agent', async () => {
|
||||
await element(by.id('new-agent-btn')).tap();
|
||||
await waitFor(element(by.text("Nom de l'agent")))
|
||||
.toBeVisible()
|
||||
.withTimeout(4000);
|
||||
});
|
||||
|
||||
it('shows the name input in the editor', async () => {
|
||||
await expect(element(by.id('agent-name-input'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows the Créer save button for a new agent', async () => {
|
||||
await expect(element(by.id('agent-save-btn'))).toBeVisible();
|
||||
await expect(element(by.text('Créer'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('fills in the agent name and saves', async () => {
|
||||
await element(by.id('agent-name-input')).clearText();
|
||||
await element(by.id('agent-name-input')).typeText(TEST_AGENT_NAME);
|
||||
await element(by.id('agent-save-btn')).tap();
|
||||
// Editor should close after save
|
||||
await waitFor(element(by.text("Nom de l'agent")))
|
||||
.not.toBeVisible()
|
||||
.withTimeout(5000);
|
||||
});
|
||||
|
||||
it('shows the newly created agent in the list', async () => {
|
||||
await waitFor(element(by.text(TEST_AGENT_NAME)))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000);
|
||||
});
|
||||
|
||||
// ─── Edit agent ───────────────────────────────────────────────────────────
|
||||
|
||||
it('opens the editor when tapping on an existing agent card', async () => {
|
||||
await element(by.text(TEST_AGENT_NAME)).tap();
|
||||
await waitFor(element(by.text("Nom de l'agent")))
|
||||
.toBeVisible()
|
||||
.withTimeout(4000);
|
||||
});
|
||||
|
||||
it('shows "Mettre à jour" button when editing an existing agent', async () => {
|
||||
await expect(element(by.text('Mettre à jour'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('closes the editor without saving when tapping Annuler', async () => {
|
||||
await element(by.text('Annuler')).tap();
|
||||
await waitFor(element(by.text("Nom de l'agent")))
|
||||
.not.toBeVisible()
|
||||
.withTimeout(4000);
|
||||
// Agent name still visible in the list
|
||||
await expect(element(by.text(TEST_AGENT_NAME))).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Delete agent ─────────────────────────────────────────────────────────
|
||||
|
||||
it('shows the delete confirmation alert when tapping the delete button', async () => {
|
||||
await element(by.id(`delete-agent-${TEST_AGENT_ID}`)).tap();
|
||||
await waitFor(element(by.text('Supprimer')))
|
||||
.toBeVisible()
|
||||
.withTimeout(4000);
|
||||
});
|
||||
|
||||
it('removes the agent after confirming deletion', async () => {
|
||||
// Tap the destructive "Supprimer" action in the Alert
|
||||
await element(by.text('Supprimer')).tap();
|
||||
await waitFor(element(by.text(TEST_AGENT_NAME)))
|
||||
.not.toBeVisible()
|
||||
.withTimeout(5000);
|
||||
});
|
||||
});
|
||||
61
app/e2e/chat.e2e.js
Normal file
61
app/e2e/chat.e2e.js
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* e2e/chat.e2e.js
|
||||
*
|
||||
* Verifies the Chat screen UI:
|
||||
* - message input renders
|
||||
* - send button renders
|
||||
* - input accepts text when no model is loaded (editable state check)
|
||||
* - send is disabled when input is empty
|
||||
* - UI shows an indication that no model is loaded
|
||||
*/
|
||||
const { navigatePastSplash, tapTab } = require('./helpers');
|
||||
|
||||
describe('Chat Screen', () => {
|
||||
beforeAll(async () => {
|
||||
await device.launchApp({ newInstance: true });
|
||||
await navigatePastSplash();
|
||||
await tapTab('Chat');
|
||||
await waitFor(element(by.id('chat-input-field')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000);
|
||||
});
|
||||
|
||||
// ─── Render ──────────────────────────────────────────────────────────────
|
||||
|
||||
it('shows the message input field', async () => {
|
||||
await expect(element(by.id('chat-input-field'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows the send button', async () => {
|
||||
await expect(element(by.id('chat-send-btn'))).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Input bar ───────────────────────────────────────────────────────────
|
||||
|
||||
it('send button is disabled when the input is empty', async () => {
|
||||
// The button is rendered but disabled when no text is entered.
|
||||
// Tapping a disabled button should not trigger a send (no crash).
|
||||
await element(by.id('chat-send-btn')).tap();
|
||||
// If we reach here without crash, the disabled guard works.
|
||||
await expect(element(by.id('chat-send-btn'))).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── New conversation ─────────────────────────────────────────────────────
|
||||
|
||||
it('renders a new conversation title area', async () => {
|
||||
// The active conversation default title is shown in the header area.
|
||||
// We just verify the screen is still stable.
|
||||
await expect(element(by.id('chat-input-field'))).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Navigation stability ────────────────────────────────────────────────
|
||||
|
||||
it('switching to another tab and back preserves the chat screen', async () => {
|
||||
await tapTab('Agents');
|
||||
await waitFor(element(by.id('agents-screen-title'))).toBeVisible().withTimeout(4000);
|
||||
await tapTab('Chat');
|
||||
await waitFor(element(by.id('chat-input-field')))
|
||||
.toBeVisible()
|
||||
.withTimeout(4000);
|
||||
});
|
||||
});
|
||||
27
app/e2e/first.e2e.js
Normal file
27
app/e2e/first.e2e.js
Normal file
@@ -0,0 +1,27 @@
|
||||
const { navigatePastSplash } = require('./helpers');
|
||||
|
||||
describe('App basic e2e', () => {
|
||||
beforeAll(async () => {
|
||||
await device.launchApp({ newInstance: true });
|
||||
});
|
||||
|
||||
it('shows the landing splash screen', async () => {
|
||||
// The landing screen shows a logo for 5 seconds before navigating
|
||||
await waitFor(element(by.id('landing-logo')))
|
||||
.toBeVisible()
|
||||
.withTimeout(10000);
|
||||
});
|
||||
|
||||
it('navigates to main tabs after the splash', async () => {
|
||||
// Landing screen auto-navigates after 5s
|
||||
await waitFor(element(by.text('Modèles')))
|
||||
.toBeVisible()
|
||||
.withTimeout(10000);
|
||||
});
|
||||
|
||||
it('shows the Models sub-tabs after landing', async () => {
|
||||
await waitFor(element(by.text('Modèles locaux')).atIndex(0))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000);
|
||||
});
|
||||
});
|
||||
29
app/e2e/helpers.js
Normal file
29
app/e2e/helpers.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Shared Detox helpers for e2e tests.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wait for the app to pass the 5-second landing splash and reach MainTabs.
|
||||
* Call this in beforeAll() after device.launchApp().
|
||||
*/
|
||||
async function navigatePastSplash() {
|
||||
// 1. Splash logo must appear first
|
||||
await waitFor(element(by.id('landing-logo')))
|
||||
.toBeVisible()
|
||||
.withTimeout(10000);
|
||||
|
||||
// 2. After the 5-second timer, the app navigates to MainTabs.
|
||||
// The bottom tab label "Modèles" is the first visible tab bar item.
|
||||
await waitFor(element(by.text('Modèles')))
|
||||
.toBeVisible()
|
||||
.withTimeout(12000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tap a bottom tab by its visible label.
|
||||
*/
|
||||
async function tapTab(label) {
|
||||
await element(by.text(label)).tap();
|
||||
}
|
||||
|
||||
module.exports = { navigatePastSplash, tapTab };
|
||||
11
app/e2e/jest.config.js
Normal file
11
app/e2e/jest.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
maxWorkers: 1,
|
||||
rootDir: '..',
|
||||
testMatch: ['<rootDir>/e2e/**/*.e2e.[jt]s?(x)'],
|
||||
testTimeout: 120000,
|
||||
verbose: true,
|
||||
reporters: ['detox/runners/jest/reporter'],
|
||||
globalSetup: 'detox/runners/jest/globalSetup',
|
||||
globalTeardown: 'detox/runners/jest/globalTeardown',
|
||||
testEnvironment: 'detox/runners/jest/testEnvironment'
|
||||
};
|
||||
86
app/e2e/navigation.e2e.js
Normal file
86
app/e2e/navigation.e2e.js
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* e2e/navigation.e2e.js
|
||||
*
|
||||
* Verifies that every bottom-tab destination renders correctly.
|
||||
*/
|
||||
const { navigatePastSplash, tapTab } = require('./helpers');
|
||||
|
||||
describe('Tab Navigation', () => {
|
||||
beforeAll(async () => {
|
||||
await device.launchApp({ newInstance: true });
|
||||
await navigatePastSplash();
|
||||
});
|
||||
|
||||
// ─── Models (default tab) ────────────────────────────────────────────────
|
||||
|
||||
it('opens the Models tab by default', async () => {
|
||||
// MaterialTopTabNavigator renders tab labels twice (tab bar + pager) — use atIndex(0)
|
||||
await waitFor(element(by.text('Mod\u00e8les locaux')).atIndex(0))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000);
|
||||
});
|
||||
|
||||
it('shows the HuggingFace sub-tab inside Models', async () => {
|
||||
await waitFor(element(by.text('HuggingFace')).atIndex(0))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000);
|
||||
});
|
||||
|
||||
// ─── Chat ────────────────────────────────────────────────────────────────
|
||||
|
||||
it('navigates to Chat tab and shows the message input', async () => {
|
||||
await tapTab('Chat');
|
||||
await waitFor(element(by.id('chat-input-field')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000);
|
||||
});
|
||||
|
||||
it('shows the send button in Chat tab', async () => {
|
||||
await waitFor(element(by.id('chat-send-btn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(3000);
|
||||
});
|
||||
|
||||
// ─── Agents ──────────────────────────────────────────────────────────────
|
||||
|
||||
it('navigates to Agents tab and shows the header', async () => {
|
||||
await tapTab('Agents');
|
||||
// Use testID to avoid matching the bottom tab bar label as well
|
||||
await waitFor(element(by.id('agents-screen-title')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000);
|
||||
});
|
||||
|
||||
it('shows the new-agent button in Agents tab', async () => {
|
||||
await waitFor(element(by.id('new-agent-btn')))
|
||||
.toBeVisible()
|
||||
.withTimeout(3000);
|
||||
});
|
||||
|
||||
// ─── Hardware ────────────────────────────────────────────────────────────
|
||||
|
||||
it('navigates to Matériel tab and shows hardware page title', async () => {
|
||||
await tapTab('Matériel');
|
||||
await waitFor(element(by.text('🖥 Matériel')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000);
|
||||
});
|
||||
|
||||
// ─── Settings ────────────────────────────────────────────────────────────
|
||||
|
||||
it('navigates to Réglages tab and shows Settings title', async () => {
|
||||
await tapTab('Réglages');
|
||||
await waitFor(element(by.text('Settings')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000);
|
||||
});
|
||||
|
||||
// ─── Back to Models ──────────────────────────────────────────────────────
|
||||
|
||||
it('navigates back to Models tab', async () => {
|
||||
await tapTab('Modèles');
|
||||
await waitFor(element(by.text('Mod\u00e8les locaux')).atIndex(0))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000);
|
||||
});
|
||||
});
|
||||
109
app/e2e/run-detox.js
Normal file
109
app/e2e/run-detox.js
Normal file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
const { exec, spawn } = require('child_process');
|
||||
|
||||
const ROOT_DIR = __dirname + '/..';
|
||||
|
||||
function run(cmd) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const p = exec(cmd, { cwd: ROOT_DIR, maxBuffer: 1024 * 1024 * 10 });
|
||||
p.stdout.pipe(process.stdout);
|
||||
p.stderr.pipe(process.stderr);
|
||||
p.on('close', code => (code === 0 ? resolve() : reject(new Error(cmd + ' exited with ' + code))));
|
||||
});
|
||||
}
|
||||
|
||||
function adbList() {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec('adb devices -l', (err, stdout) => {
|
||||
if (err) return reject(err);
|
||||
resolve(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Metro is already listening on port 8081.
|
||||
*/
|
||||
function isMetroRunning() {
|
||||
return new Promise(resolve => {
|
||||
exec('lsof -iTCP:8081 -sTCP:LISTEN -t', (err, stdout) => {
|
||||
resolve(!err && stdout.trim().length > 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Metro bundler in the background. Returns a cleanup function.
|
||||
*/
|
||||
function startMetro() {
|
||||
console.log('[run-detox] Starting Metro bundler on port 8081...');
|
||||
const metro = spawn('npx', ['react-native', 'start', '--port', '8081', '--reset-cache'], {
|
||||
cwd: ROOT_DIR,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
});
|
||||
metro.stdout.on('data', d => process.stdout.write('[metro] ' + d));
|
||||
metro.stderr.on('data', d => process.stderr.write('[metro] ' + d));
|
||||
metro.on('error', err => console.error('[run-detox] Metro error:', err.message));
|
||||
return metro;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until Metro is ready (listening on 8081), with a timeout.
|
||||
*/
|
||||
function waitForMetro(timeoutMs = 60000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now();
|
||||
const interval = setInterval(() => {
|
||||
exec('lsof -iTCP:8081 -sTCP:LISTEN -t', (err, stdout) => {
|
||||
if (!err && stdout.trim().length > 0) {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
} else if (Date.now() - start > timeoutMs) {
|
||||
clearInterval(interval);
|
||||
reject(new Error('Timed out waiting for Metro to start on port 8081'));
|
||||
}
|
||||
});
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
let metroProcess = null;
|
||||
try {
|
||||
const out = await adbList();
|
||||
const lines = out.split('\n').slice(1).map(l => l.trim()).filter(Boolean);
|
||||
// filter out emulator entries (emulator-*) and look for 'device' state
|
||||
const attached = lines
|
||||
.map(l => l.split(/\s+/)[0])
|
||||
.filter(id => id && !id.startsWith('emulator-'));
|
||||
|
||||
const useConfig = attached.length > 0 ? 'android.attached+android.debug' : 'android.emu.debug+android.debug';
|
||||
console.log('\n[run-detox] Detected attached devices:', attached.join(', ') || '(none)');
|
||||
console.log('[run-detox] Using configuration:', useConfig, '\n');
|
||||
|
||||
// Ensure Metro is running (required for debug APK to load the JS bundle)
|
||||
const metroAlready = await isMetroRunning();
|
||||
if (metroAlready) {
|
||||
console.log('[run-detox] Metro is already running on port 8081.');
|
||||
} else {
|
||||
metroProcess = startMetro();
|
||||
console.log('[run-detox] Waiting for Metro to be ready (up to 60s)...');
|
||||
await waitForMetro(60000);
|
||||
console.log('[run-detox] Metro is ready.\n');
|
||||
}
|
||||
|
||||
console.log('Building...');
|
||||
await run(`npx detox build -c ${useConfig}`);
|
||||
console.log('Running tests...');
|
||||
await run(`npx detox test -c ${useConfig}`);
|
||||
} catch (err) {
|
||||
console.error('\n[run-detox] Error:', err.message || err);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (metroProcess) {
|
||||
console.log('\n[run-detox] Stopping Metro bundler...');
|
||||
metroProcess.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
})();
|
||||
61
app/e2e/settings.e2e.js
Normal file
61
app/e2e/settings.e2e.js
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* e2e/settings.e2e.js
|
||||
*
|
||||
* Verifies the Settings (Réglages) screen:
|
||||
* - renders correctly
|
||||
* - all four theme buttons are tappable
|
||||
* - theme switches don't crash the app
|
||||
*/
|
||||
const { navigatePastSplash, tapTab } = require('./helpers');
|
||||
|
||||
describe('Settings Screen', () => {
|
||||
beforeAll(async () => {
|
||||
await device.launchApp({ newInstance: true });
|
||||
await navigatePastSplash();
|
||||
await tapTab('Réglages');
|
||||
await waitFor(element(by.text('Settings')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000);
|
||||
});
|
||||
|
||||
// ─── Render ──────────────────────────────────────────────────────────────
|
||||
|
||||
it('shows the Settings title', async () => {
|
||||
await expect(element(by.text('Settings'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows all four theme buttons', async () => {
|
||||
await expect(element(by.id('theme-btn-light'))).toBeVisible();
|
||||
await expect(element(by.id('theme-btn-system'))).toBeVisible();
|
||||
await expect(element(by.id('theme-btn-dark'))).toBeVisible();
|
||||
await expect(element(by.id('theme-btn-neon'))).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Theme switching ─────────────────────────────────────────────────────
|
||||
|
||||
it('switches to Dark theme without crashing', async () => {
|
||||
await element(by.id('theme-btn-dark')).tap();
|
||||
await expect(element(by.text('Settings'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('switches to Neon theme without crashing', async () => {
|
||||
await element(by.id('theme-btn-neon')).tap();
|
||||
await expect(element(by.text('Settings'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('switches to Light theme without crashing', async () => {
|
||||
await element(by.id('theme-btn-light')).tap();
|
||||
await expect(element(by.text('Settings'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('switches to System theme without crashing', async () => {
|
||||
await element(by.id('theme-btn-system')).tap();
|
||||
await expect(element(by.text('Settings'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('tapping the already-active theme twice does not crash', async () => {
|
||||
await element(by.id('theme-btn-light')).tap();
|
||||
await element(by.id('theme-btn-light')).tap();
|
||||
await expect(element(by.text('Settings'))).toBeVisible();
|
||||
});
|
||||
});
|
||||
1177
app/package-lock.json
generated
1177
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,13 @@
|
||||
"ios": "react-native run-ios",
|
||||
"lint": "eslint .",
|
||||
"start": "react-native start",
|
||||
"test": "jest"
|
||||
"test": "jest",
|
||||
"test:unit": "jest",
|
||||
"test:e2e:build:android": "detox build -c android.emu.debug",
|
||||
"test:e2e:run:android": "detox test -c android.emu.debug",
|
||||
"test:e2e:android": "npm run test:e2e:build:android && npm run test:e2e:run:android",
|
||||
"test:full": "npm run test:unit && npm run test:e2e:android",
|
||||
"test:e2e:auto": "node ./e2e/run-detox.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
@@ -51,7 +57,9 @@
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"prettier": "2.8.8",
|
||||
"react-test-renderer": "19.2.3",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.8.3",
|
||||
"detox": "^20.47.0",
|
||||
"jest-circus": "^29.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 22.11.0"
|
||||
|
||||
@@ -40,6 +40,8 @@ export default function AgentEditor({ visible, agent, onClose }: Props) {
|
||||
const [avatarUri, setAvatarUri] = useState<string | null>(
|
||||
agent?.avatarUri ?? null,
|
||||
);
|
||||
const [toolNow, setToolNow] = useState(agent?.tools?.now ?? false);
|
||||
const [toolUsername, setToolUsername] = useState(agent?.tools?.username ?? false);
|
||||
const [pickingImage, setPickingImage] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -49,6 +51,8 @@ export default function AgentEditor({ visible, agent, onClose }: Props) {
|
||||
setName(agent?.name ?? '');
|
||||
setPrompt(agent?.systemPrompt ?? 'Tu es un assistant utile et concis.');
|
||||
setAvatarUri(agent?.avatarUri ?? null);
|
||||
setToolNow(agent?.tools?.now ?? false);
|
||||
setToolUsername(agent?.tools?.username ?? false);
|
||||
}
|
||||
}, [visible, agent]);
|
||||
|
||||
@@ -91,6 +95,7 @@ export default function AgentEditor({ visible, agent, onClose }: Props) {
|
||||
name: name.trim(),
|
||||
systemPrompt: prompt.trim(),
|
||||
avatarUri: avatarUri ?? null,
|
||||
tools: { now: toolNow, username: toolUsername },
|
||||
};
|
||||
await dispatch(saveAgent(updated));
|
||||
onClose();
|
||||
@@ -161,6 +166,7 @@ export default function AgentEditor({ visible, agent, onClose }: Props) {
|
||||
placeholder="Ex: Expert Python"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
maxLength={60}
|
||||
testID="agent-name-input"
|
||||
/>
|
||||
|
||||
{/* System prompt */}
|
||||
@@ -175,6 +181,32 @@ export default function AgentEditor({ visible, agent, onClose }: Props) {
|
||||
numberOfLines={6}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
|
||||
{/* Tools */}
|
||||
<Text style={styles.label}>Tools context</Text>
|
||||
<View style={styles.toolsRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.toolChip, toolNow && styles.toolChipActive]}
|
||||
onPress={() => setToolNow(v => !v)}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
<Text style={[styles.toolChipText, toolNow && styles.toolChipTextActive]}>
|
||||
🕒️ now
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.toolChip, toolUsername && styles.toolChipActive]}
|
||||
onPress={() => setToolUsername(v => !v)}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
<Text style={[styles.toolChipText, toolUsername && styles.toolChipTextActive]}>
|
||||
👤 username
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={styles.toolsHint}>
|
||||
Quand activés, ces données sont injectées automatiquement dans le contexte de l’agent au moment de l’inférence.
|
||||
</Text>
|
||||
</ScrollView>
|
||||
|
||||
{/* Actions */}
|
||||
@@ -186,6 +218,7 @@ export default function AgentEditor({ visible, agent, onClose }: Props) {
|
||||
style={[styles.saveBtn, saving && styles.saveBtnSaving]}
|
||||
onPress={handleSave}
|
||||
disabled={saving}
|
||||
testID="agent-save-btn"
|
||||
>
|
||||
{saving ? (
|
||||
<ActivityIndicator color={colors.surface} size="small" />
|
||||
@@ -341,4 +374,39 @@ const styles = StyleSheet.create({
|
||||
color: colors.surface,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
toolsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
toolChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 7,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
toolChipActive: {
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: colors.agentBg ?? colors.primary + '18',
|
||||
},
|
||||
toolChipText: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: typography.weights.medium,
|
||||
},
|
||||
toolChipTextActive: {
|
||||
color: colors.primary,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
toolsHint: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textTertiary,
|
||||
lineHeight: 17,
|
||||
marginTop: 2,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -87,6 +87,7 @@ export default function AgentsScreen() {
|
||||
<TouchableOpacity
|
||||
style={styles.deleteBtn}
|
||||
onPress={() => handleDelete(item)}
|
||||
testID={`delete-agent-${item.name.replace(/\s+/g, '-')}`}
|
||||
>
|
||||
<Text style={styles.deleteBtnText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -99,8 +100,8 @@ export default function AgentsScreen() {
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Agents</Text>
|
||||
<TouchableOpacity style={styles.addBtn} onPress={handleNew}>
|
||||
<Text style={styles.headerTitle} testID="agents-screen-title">Agents</Text>
|
||||
<TouchableOpacity style={styles.addBtn} onPress={handleNew} testID="new-agent-btn">
|
||||
<Text style={styles.addBtnText}>+ Nouvel agent</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -35,6 +35,7 @@ export default function ChatInput({
|
||||
multiline
|
||||
maxLength={2000}
|
||||
editable={!disabled}
|
||||
testID="chat-input-field"
|
||||
/>
|
||||
{isGenerating ? (
|
||||
<TouchableOpacity
|
||||
@@ -48,6 +49,7 @@ export default function ChatInput({
|
||||
style={[themeStyles.sendButton, disabled && themeStyles.sendButtonDisabled]}
|
||||
onPress={onSend}
|
||||
disabled={disabled || !value.trim()}
|
||||
testID="chat-send-btn"
|
||||
>
|
||||
<Text style={themeStyles.sendButtonText}>➤</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { Message } from '../../store/chatSlice';
|
||||
@@ -29,8 +31,13 @@ export default function MessageBubble({ message }: MessageBubbleProps) {
|
||||
? (message.metadata?.agentName as string | undefined) : undefined;
|
||||
const modelName = !isUser
|
||||
? (message.metadata?.modelName as string | undefined) : undefined;
|
||||
const isThinking = !isUser
|
||||
? (message.metadata?.isThinking as boolean | undefined) : undefined;
|
||||
const reasoningContent = !isUser
|
||||
? (message.metadata?.reasoningContent as string | undefined) : undefined;
|
||||
|
||||
const [showModel, setShowModel] = useState(false);
|
||||
const [showThinking, setShowThinking] = useState(false);
|
||||
const { colors } = useTheme();
|
||||
const themeStyles = createStyles(colors);
|
||||
|
||||
@@ -39,6 +46,37 @@ export default function MessageBubble({ message }: MessageBubbleProps) {
|
||||
{agentName && (
|
||||
<Text style={themeStyles.agentLabel}>🤖 {agentName}</Text>
|
||||
)}
|
||||
|
||||
{/* ── Thinking indicator ────────────────────────────────────────── */}
|
||||
{!isUser && isThinking && (
|
||||
<View style={themeStyles.thinkingSpinnerRow}>
|
||||
<ActivityIndicator size="small" color={colors.textTertiary as string} />
|
||||
<Text style={themeStyles.thinkingSpinnerText}>Thinking...</Text>
|
||||
</View>
|
||||
)}
|
||||
{!isUser && !isThinking && reasoningContent ? (
|
||||
<View style={themeStyles.thinkingWrapper}>
|
||||
<TouchableOpacity
|
||||
style={themeStyles.thinkingBadge}
|
||||
onPress={() => setShowThinking(v => !v)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={themeStyles.thinkingBadgeText}>
|
||||
🧠 Thinking {showThinking ? '▾' : '▸'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{showThinking && (
|
||||
<View style={themeStyles.thinkingCard}>
|
||||
<ScrollView style={themeStyles.thinkingScroll} nestedScrollEnabled>
|
||||
<Text style={themeStyles.thinkingCardText}>{reasoningContent}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* ── Main message bubble (hidden while thinking, always shown after) ── */}
|
||||
{!isThinking && (
|
||||
<Pressable
|
||||
onLongPress={() =>
|
||||
navigation.navigate('MessageDetails', { messageId: message.id })
|
||||
@@ -57,6 +95,8 @@ export default function MessageBubble({ message }: MessageBubbleProps) {
|
||||
{time}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
<View style={themeStyles.metaRow}>
|
||||
{liveRate !== undefined && (
|
||||
<Text style={themeStyles.rate}>{liveRate.toFixed(1)} tok/s</Text>
|
||||
@@ -144,4 +184,54 @@ const createStyles = (colors: Record<string, unknown>) =>
|
||||
marginTop: 2,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
// ── Thinking styles ──────────────────────────────────────────────────
|
||||
thinkingSpinnerRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
},
|
||||
thinkingSpinnerText: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textTertiary,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
thinkingWrapper: {
|
||||
maxWidth: '80%',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
thinkingBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.surfaceTertiary ?? colors.surfaceSecondary,
|
||||
borderRadius: borderRadius.sm,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
thinkingBadgeText: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
thinkingCard: {
|
||||
marginTop: spacing.xs,
|
||||
backgroundColor: colors.surfaceTertiary ?? colors.surfaceSecondary,
|
||||
borderRadius: borderRadius.md,
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: colors.textTertiary as string,
|
||||
padding: spacing.sm,
|
||||
maxHeight: 220,
|
||||
},
|
||||
thinkingScroll: {
|
||||
flexGrow: 0,
|
||||
},
|
||||
thinkingCardText: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 16,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export default function LandingScreen() {
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
accessibilityLabel="My Mobile Agent"
|
||||
testID="landing-logo"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity } from 'react-native';
|
||||
import { createStyles } from './styles';
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { useTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
interface PermissionMessageProps {
|
||||
@@ -11,24 +10,62 @@ export default function PermissionMessage({
|
||||
onRequestPermission,
|
||||
}: PermissionMessageProps) {
|
||||
const { colors } = useTheme();
|
||||
const styles = createStyles(colors);
|
||||
|
||||
return (
|
||||
<View style={styles.permissionContainer}>
|
||||
<Text style={styles.permissionText}>
|
||||
📁 Permission requise
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<Text style={styles.icon}>📁</Text>
|
||||
<Text style={[styles.title, { color: colors.text }]}>
|
||||
Permission requise
|
||||
</Text>
|
||||
<Text style={styles.permissionSubtext}>
|
||||
L'application a besoin d'accéder aux fichiers pour lire vos modèles GGUF
|
||||
<Text style={[styles.subtitle, { color: colors.text }]}>
|
||||
L'application a besoin d'accéder aux fichiers pour lire vos modèles GGUF.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.permissionButton}
|
||||
style={[styles.button, { backgroundColor: colors.primary }]}
|
||||
onPress={onRequestPermission}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.permissionButtonText}>
|
||||
<Text style={[styles.buttonText, { color: colors.onPrimary }]}>
|
||||
Autoriser l'accès aux fichiers
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 40,
|
||||
gap: 16,
|
||||
},
|
||||
icon: {
|
||||
fontSize: 56,
|
||||
marginBottom: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 15,
|
||||
textAlign: 'center',
|
||||
opacity: 0.65,
|
||||
lineHeight: 22,
|
||||
marginBottom: 8,
|
||||
},
|
||||
button: {
|
||||
paddingHorizontal: 28,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
marginTop: 8,
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -28,7 +28,6 @@ import type { ModelConfig } from '../../store/types';
|
||||
import ModelItem from './ModelItem';
|
||||
import DirectoryPicker from './DirectoryPicker';
|
||||
import DirectoryEditor from './DirectoryEditor';
|
||||
import PermissionMessage from './PermissionMessage';
|
||||
import ModelsList from './ModelsList';
|
||||
import { createStyles } from './styles';
|
||||
import { useTheme } from '../../theme/ThemeProvider';
|
||||
@@ -381,9 +380,6 @@ export default function LocalModelsScreen() {
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{!hasPermission && (
|
||||
<PermissionMessage onRequestPermission={handleRequestPermission} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Models List */}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { AppState } from 'react-native';
|
||||
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { useTheme } from '../theme/ThemeProvider';
|
||||
import LocalModelsScreen from './LocalModelsScreen';
|
||||
import HuggingFaceModelsScreen from './HuggingFaceModelsScreen';
|
||||
import PermissionMessage from './LocalModelsScreen/PermissionMessage';
|
||||
import {
|
||||
checkStoragePermission,
|
||||
requestStoragePermission,
|
||||
} from '../utils/permissions';
|
||||
|
||||
export type ModelsTabParamList = {
|
||||
LocalModels: undefined;
|
||||
@@ -14,6 +21,31 @@ const Tab = createMaterialTopTabNavigator<ModelsTabParamList>();
|
||||
export default function ModelsScreen() {
|
||||
const { colors, scheme } = useTheme();
|
||||
const isDark = scheme === 'dark';
|
||||
const [hasPermission, setHasPermission] = useState(false);
|
||||
|
||||
const checkPerm = useCallback(async () => {
|
||||
const granted = await checkStoragePermission();
|
||||
setHasPermission(granted);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkPerm();
|
||||
const sub = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') checkPerm();
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, [checkPerm]);
|
||||
|
||||
useFocusEffect(useCallback(() => { checkPerm(); }, [checkPerm]));
|
||||
|
||||
const handleRequestPermission = async () => {
|
||||
await requestStoragePermission();
|
||||
setTimeout(checkPerm, 1000);
|
||||
};
|
||||
|
||||
if (!hasPermission) {
|
||||
return <PermissionMessage onRequestPermission={handleRequestPermission} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tab.Navigator
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import React, { useMemo, useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, TextInput } from 'react-native';
|
||||
import type { CompositeScreenProps } from '@react-navigation/native';
|
||||
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import type { RootStackParamList, TabParamList } from '../navigation';
|
||||
import { useTheme } from '../theme/ThemeProvider';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import type { RootState, AppDispatch } from '../store';
|
||||
import { loadSettings, saveUsername } from '../store/settingsSlice';
|
||||
|
||||
type TColors = {
|
||||
background: string;
|
||||
@@ -21,9 +24,8 @@ function makeStyles(colors: TColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 32,
|
||||
},
|
||||
text: {
|
||||
fontSize: 20,
|
||||
@@ -58,8 +60,47 @@ function makeStyles(colors: TColors) {
|
||||
segmentTextActive: {
|
||||
color: colors.onPrimary ?? '#fff',
|
||||
},
|
||||
buttonWrap: {
|
||||
marginTop: 20,
|
||||
sectionLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: colors.text,
|
||||
opacity: 0.5,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
marginTop: 28,
|
||||
marginBottom: 8,
|
||||
},
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
fontSize: 16,
|
||||
color: colors.text,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
saveBtn: {
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
saveBtnText: {
|
||||
color: colors.onPrimary ?? '#fff',
|
||||
fontWeight: '600',
|
||||
fontSize: 14,
|
||||
},
|
||||
savedHint: {
|
||||
marginTop: 6,
|
||||
fontSize: 12,
|
||||
color: colors.primary,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -72,7 +113,26 @@ type Props = CompositeScreenProps<
|
||||
|
||||
export default function SettingsScreen({}: Props) {
|
||||
const { mode, setMode, colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const styles = useMemo(() => makeStyles(colors as unknown as TColors), [colors]);
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const storedUsername = useSelector((s: RootState) => s.settings.username);
|
||||
|
||||
const [localUsername, setLocalUsername] = useState(storedUsername);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(loadSettings());
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalUsername(storedUsername);
|
||||
}, [storedUsername]);
|
||||
|
||||
const handleSaveUsername = () => {
|
||||
dispatch(saveUsername(localUsername.trim()));
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
};
|
||||
|
||||
const items: { key: 'light' | 'system' | 'dark' | 'neon'; label: string }[] = [
|
||||
{ key: 'light', label: 'Light' },
|
||||
@@ -83,8 +143,27 @@ export default function SettingsScreen({}: Props) {
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<Text style={[styles.text, { color: colors.text }]}>Settings</Text>
|
||||
{/* Username */}
|
||||
<Text style={styles.sectionLabel}>Nom d'utilisateur</Text>
|
||||
<View style={styles.inputRow}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={localUsername}
|
||||
onChangeText={v => { setLocalUsername(v); setSaved(false); }}
|
||||
placeholder="Ton prénom ou pseudo"
|
||||
placeholderTextColor={`${colors.text}60`}
|
||||
autoCorrect={false}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSaveUsername}
|
||||
/>
|
||||
<TouchableOpacity style={styles.saveBtn} onPress={handleSaveUsername}>
|
||||
<Text style={styles.saveBtnText}>Enregistrer</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{saved && <Text style={styles.savedHint}>✓ Enregistré</Text>}
|
||||
|
||||
{/* Theme */}
|
||||
<Text style={styles.sectionLabel}>Thème</Text>
|
||||
<View style={styles.segmentWrap}>
|
||||
<View style={styles.segmentBar}>
|
||||
{items.map((it) => {
|
||||
@@ -100,6 +179,7 @@ export default function SettingsScreen({}: Props) {
|
||||
]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.8}
|
||||
testID={`theme-btn-${it.key}`}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
|
||||
@@ -83,10 +83,12 @@ export const generateResponse = createAsyncThunk(
|
||||
}
|
||||
|
||||
const agentsState = (getState() as RootState).agents;
|
||||
const username = (getState() as RootState).settings?.username ?? '';
|
||||
const systemPrompt = resolveSystemPrompt(
|
||||
effectiveCfg.systemPrompt,
|
||||
params.activeAgentId,
|
||||
agentsState?.agents ?? [],
|
||||
username,
|
||||
);
|
||||
|
||||
const oaiMessages = buildOAIMessages(params.messages, systemPrompt);
|
||||
@@ -109,6 +111,9 @@ export const generateResponse = createAsyncThunk(
|
||||
let liveTokenCount = 0;
|
||||
const generationStart = Date.now();
|
||||
let lastRateDispatch = 0;
|
||||
// Track think-block state for live UI feedback
|
||||
let lastCleanLength = 0;
|
||||
let isThinkingActive = false;
|
||||
|
||||
const result: CompletionResult = await llamaContext.completion(
|
||||
samplingParams,
|
||||
@@ -132,16 +137,46 @@ export const generateResponse = createAsyncThunk(
|
||||
});
|
||||
}
|
||||
}
|
||||
// data.token is always the incremental raw piece — safe to append.
|
||||
// data.content is cumulative parsed text (think-tags stripped) so
|
||||
// we do NOT use it here; the final clean version is applied via
|
||||
// finalizeAssistantMessage after completion.
|
||||
}
|
||||
// Accumulate reasoning tokens and signal thinking state
|
||||
if (data?.reasoning_content) {
|
||||
rawStreamReasoning += data.reasoning_content;
|
||||
if (!isThinkingActive) {
|
||||
isThinkingActive = true;
|
||||
dispatch({
|
||||
type: 'chat/setMessageMeta',
|
||||
payload: { id: params.assistantMessageId, meta: { isThinking: true } },
|
||||
});
|
||||
}
|
||||
}
|
||||
// Forward only the clean response via delta of cumulative data.content
|
||||
// (think-tags already stripped by llama.rn with reasoning_format:'auto')
|
||||
if (data?.content !== undefined && data.content.length > lastCleanLength) {
|
||||
const delta = data.content.slice(lastCleanLength);
|
||||
lastCleanLength = data.content.length;
|
||||
if (isThinkingActive) {
|
||||
isThinkingActive = false;
|
||||
dispatch({
|
||||
type: 'chat/setMessageMeta',
|
||||
payload: { id: params.assistantMessageId, meta: { isThinking: false } },
|
||||
});
|
||||
}
|
||||
if (params.onToken) { params.onToken(delta); }
|
||||
} else if (token && !data?.reasoning_content && data?.content === undefined) {
|
||||
// Fallback: model without thinking support — forward raw token directly
|
||||
if (params.onToken) { params.onToken(token); }
|
||||
}
|
||||
if (data?.reasoning_content) { rawStreamReasoning += data.reasoning_content; }
|
||||
},
|
||||
);
|
||||
|
||||
// Ensure isThinking is cleared if model ended inside a think block
|
||||
if (isThinkingActive) {
|
||||
dispatch({
|
||||
type: 'chat/setMessageMeta',
|
||||
payload: { id: params.assistantMessageId, meta: { isThinking: false } },
|
||||
});
|
||||
}
|
||||
|
||||
const elapsed = (Date.now() - generationStart) / 1000;
|
||||
dispatch({
|
||||
type: 'chat/setMessageMeta',
|
||||
|
||||
@@ -153,15 +153,36 @@ export function resolveActiveStops(
|
||||
/**
|
||||
* Compose the effective system prompt: inject the selected agent's name and
|
||||
* system prompt in front of the model-level base prompt (if any).
|
||||
* Active tools are injected as context lines BEFORE the agent prompt.
|
||||
*/
|
||||
export function resolveSystemPrompt(
|
||||
basePrompt: string | undefined,
|
||||
activeAgentId: string | null | undefined,
|
||||
agents: Agent[],
|
||||
username?: string,
|
||||
): string | undefined {
|
||||
if (!activeAgentId) { return basePrompt; }
|
||||
const agent = agents.find(a => a.id === activeAgentId);
|
||||
if (!agent) { return basePrompt; }
|
||||
const agentBlock = `[Agent: ${agent.name}]\n${agent.systemPrompt}`;
|
||||
|
||||
// Build tools context block
|
||||
const toolLines: string[] = [];
|
||||
if (agent.tools?.now) {
|
||||
const now = new Date();
|
||||
const formatted = now.toLocaleString('fr-FR', {
|
||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
toolLines.push(`Date et heure actuelles : ${formatted}`);
|
||||
}
|
||||
if (agent.tools?.username && username) {
|
||||
toolLines.push(`Nom de l'utilisateur : ${username}`);
|
||||
}
|
||||
|
||||
const toolsBlock = toolLines.length > 0
|
||||
? `[Contexte]\n${toolLines.join('\n')}\n`
|
||||
: '';
|
||||
|
||||
const agentBlock = `${toolsBlock}[Agent: ${agent.name}]\n${agent.systemPrompt}`;
|
||||
return basePrompt ? `${agentBlock}\n\n${basePrompt}` : agentBlock;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import modelsReducer from './modelsSlice';
|
||||
import chatReducer from './chatSlice';
|
||||
import hardwareReducer from './hardwareSlice';
|
||||
import agentsReducer from './agentsSlice';
|
||||
import settingsReducer from './settingsSlice';
|
||||
import { debouncedSaveChatState } from './persistence';
|
||||
|
||||
const store = configureStore({
|
||||
@@ -11,6 +12,7 @@ const store = configureStore({
|
||||
chat: chatReducer,
|
||||
hardware: hardwareReducer,
|
||||
agents: agentsReducer,
|
||||
settings: settingsReducer,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
46
app/src/store/settingsSlice.ts
Normal file
46
app/src/store/settingsSlice.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
const STORAGE_KEY = '@settings_v1';
|
||||
|
||||
export interface SettingsState {
|
||||
username: string;
|
||||
}
|
||||
|
||||
const initialState: SettingsState = { username: '' };
|
||||
|
||||
export const loadSettings = createAsyncThunk('settings/load', async () => {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) { return initialState; }
|
||||
try {
|
||||
return JSON.parse(raw) as SettingsState;
|
||||
} catch {
|
||||
return initialState;
|
||||
}
|
||||
});
|
||||
|
||||
export const saveUsername = createAsyncThunk(
|
||||
'settings/saveUsername',
|
||||
async (username: string) => {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
const current = raw ? (JSON.parse(raw) as Partial<SettingsState>) : {};
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ ...current, username }));
|
||||
return username;
|
||||
},
|
||||
);
|
||||
|
||||
const settingsSlice = createSlice({
|
||||
name: 'settings',
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(loadSettings.fulfilled, (state, action) => {
|
||||
state.username = action.payload.username ?? '';
|
||||
});
|
||||
builder.addCase(saveUsername.fulfilled, (state, action) => {
|
||||
state.username = action.payload;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default settingsSlice.reducer;
|
||||
@@ -139,6 +139,11 @@ export interface Agent {
|
||||
systemPrompt: string;
|
||||
/** URI du fichier image local (dans DocumentDirectoryPath) ou null */
|
||||
avatarUri?: string | null;
|
||||
/** Tools actifs pour cet agent — injectés automatiquement dans le prompt système */
|
||||
tools?: {
|
||||
now?: boolean;
|
||||
username?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentsState {
|
||||
|
||||
@@ -67,10 +67,7 @@ export const ThemeProvider: React.FC<{
|
||||
if (scheme === 'neon') return neonColors;
|
||||
|
||||
// light
|
||||
return {
|
||||
...(lightColors as Record<string, string>),
|
||||
...(lightOverrides as Record<string, string>),
|
||||
} as Record<string, string>;
|
||||
return lightColors as unknown as Record<string, string>;
|
||||
}, [scheme]);
|
||||
|
||||
return (
|
||||
|
||||
3
app/src/theme/tokens.ts
Normal file
3
app/src/theme/tokens.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Re-export shared design tokens from lightTheme so both
|
||||
// "theme/tokens" and "theme/lightTheme" imports resolve correctly.
|
||||
export { colors, spacing, typography, borderRadius } from './lightTheme';
|
||||
Reference in New Issue
Block a user