diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 00000000..be438580
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 00000000..be438580
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
index 645045e7..e8aa8294 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 00000000..6133cce4
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
index 8d8f741c..e8aa8294 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
index 08ac3d2a..43f517e1 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 00000000..26302807
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
index 5ef55250..43f517e1 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
index 63b74109..6ef0fa7c 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 00000000..a21a49fb
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
index fbac7edb..6ef0fa7c 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
index e2cd306e..7c546705 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 00000000..712a0f5a
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
index 4ad9058a..7c546705 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
index 9bf4f6c9..c8cd0f29 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 00000000..5b5ab24b
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
index 82c2c345..c8cd0f29 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 00000000..cdde88fb
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #000000
+
diff --git a/assets/branding/README.md b/assets/branding/README.md
index 7996bf0a..3ba84040 100644
--- a/assets/branding/README.md
+++ b/assets/branding/README.md
@@ -5,6 +5,13 @@ Esta pasta guarda os assets oficiais da identidade **Forty-Two AI**.
## Asset atual
- `forty-two-ai-logo.webp` — versão otimizada para apresentação no repositório.
+- `forty-two-ai-playstore-icon.png` — ícone canônico de distribuição Android em 512 × 512 px.
+
+Os ícones de launcher instalados no Android derivam do pacote oficial gerado em
+1º de setembro de 2026. Em Android 8.0 ou superior, o aplicativo usa ícone
+adaptativo com fundo preto; nas versões anteriores, usa os PNGs legados por
+densidade. O ícone redondo mantém a mesma arte e deixa o launcher aplicar sua
+máscara nativa.
O desenho deve ser preservado sem recolorações arbitrárias, deformação, cortes ou simplificações que comprometam o símbolo, o lettering e a composição metálica original.
diff --git a/assets/branding/forty-two-ai-playstore-icon.png b/assets/branding/forty-two-ai-playstore-icon.png
new file mode 100644
index 00000000..ecf4fcfc
Binary files /dev/null and b/assets/branding/forty-two-ai-playstore-icon.png differ
diff --git a/docs/forty-two/PROJECT_CANON.md b/docs/forty-two/PROJECT_CANON.md
index 3336b214..9a8f370d 100644
--- a/docs/forty-two/PROJECT_CANON.md
+++ b/docs/forty-two/PROJECT_CANON.md
@@ -1,6 +1,6 @@
# Forty-Two AI — Project Canon
-**Last continuity checkpoint:** 2026-08-18 00:38 BRT (UTC-03:00)
+**Last continuity checkpoint:** 2026-09-02 07:07 BRT (UTC-03:00)
## Canonical project
@@ -38,7 +38,7 @@ The current master artifacts are tracked by SHA-256 so they remain identifiable
- logo/wordmark master, 1254×1254 PNG: `b49a78296320da607b2ea61eb17ce14ef33c5fa518085b18a1ff9d0488729738`
- splash master, 1586×992 PNG: `07537b6d6abce8e5d0643fbb00c4139705e384e0aa7b6f4fda12f053594ae786`
- startup intro MP4, 688×752 H.264/AAC, ~12.04 s: `6ba07d556b4026824d333c8f24ad1f35b4f48ac1388f319231961f9756493419`
-- Android launcher asset bundle: `6a9d522241cbd5dd18a30f45e8bb3bf43ca8fac40731831c550dd40331a85153`
+- Android launcher asset bundle, added to Project Files as `Forty Two AI icons.zip`: `2786a44e36b0572e42041a980d25e85a0bede6f8bfa7f67f9970762847e9c4ca`
## What belongs in Git
@@ -70,6 +70,22 @@ Current preserved reference-only artifacts:
Forty-Two AI is local-first and chat-first. The current acceleration target is the Blackview MEGA 3 / Mali-G57 MC2. CPU fallback must remain valid. Hardware capability detection must never be presented as proof that an inference backend is actually active: requested backend, effective backend and effective offload must be reported separately.
+## Active implementation state
+
+- Canonical `main` observed at `3a311bd36469b2584ae518537b45339bdbbc550a`.
+- Draft PR #1, branch `runtime/orchestrator-v1`, remains the isolated runtime-orchestrator foundation; its observed head is `c41e1438440291b8ee34e3a6dd58ee8f814be767`.
+- Draft PR #2, branch `ui/forty-two-chat-shell-v1`, is the active UI integration line. The current slice starts from `9cf72644b189965fdec333d93a00607c3fe811c5` and installs the official Android launcher assets, adaptive-icon definitions, Play Store derivative and direct quick-panel access to the existing model/Pal picker and generation parameters.
+- The UI slice does not modify the Kotlin/JNI bridge or claim Vulkan execution. Runtime integration remains separate until the branches are reconciled deliberately and effective-backend telemetry is verified on device.
+
+### Verification at this checkpoint
+
+- TypeScript compilation: passed.
+- Repository ESLint: passed with 0 errors and 7 pre-existing warnings in unrelated files.
+- Localization and bundled-font validation: passed.
+- `ChatHeader` and `HeaderRight` callback-routing tests: 13 passed.
+- Android launcher dimensions, PNG readability, XML parsing and resource-variant inventory: passed.
+- Local Gradle resource processing: not completed because the isolated environment could not reach `services.gradle.org` to download Gradle 9.0.0; repository CI is the authoritative next build check.
+
## Chronology rule
Human-readable project checkpoints and continuity documents should include an absolute **date + local time + timezone**. Git commit timestamps remain authoritative for code history. This avoids ambiguity when long chats freeze, resume later or are reconstructed from Project Files.
diff --git a/src/components/ChatHeader/ChatHeader.tsx b/src/components/ChatHeader/ChatHeader.tsx
index eca678d5..c5deee0f 100644
--- a/src/components/ChatHeader/ChatHeader.tsx
+++ b/src/components/ChatHeader/ChatHeader.tsx
@@ -5,7 +5,7 @@ import {observer} from 'mobx-react';
import {createStyles} from './styles';
import {HeaderRight} from '../HeaderRight';
import {ChatHeaderTitle} from '../ChatHeaderTitle';
-import {EnterpriseQuickPanel} from '../EnterpriseQuickPanel';
+import {FortyTwoQuickPanel} from '../FortyTwoQuickPanel';
import {
useSafeAreaFrame,
useSafeAreaInsets,
@@ -15,7 +15,13 @@ import {useTheme} from '../../hooks';
import {chatSessionStore} from '../../store';
import {HeaderLeft} from '../HeaderLeft';
-export const ChatHeader: React.FC = observer(() => {
+interface Props {
+ onOpenModelPalPicker?: () => void;
+ onOpenGenerationSettings?: () => void;
+}
+
+export const ChatHeader = observer((props: Props) => {
+ const {onOpenModelPalPicker, onOpenGenerationSettings} = props;
const theme = useTheme();
const insets = useSafeAreaInsets();
@@ -24,12 +30,10 @@ export const ChatHeader: React.FC = observer(() => {
// On models with Dynamic Island the status bar height is smaller than the safe area top inset.
// https://github.com/react-navigation/react-navigation/blob/e4815c538536ddccf4207b87bf3e2f1603dedd84/packages/elements/src/Header/Header.tsx#L52
// NOTE: in v7, this is fixed and getDefaultHeaderHeight returns the correct height.
-
const hasDynamicIsland = Platform.OS === 'ios' && insets.top > 50;
const statusBarHeight = hasDynamicIsland ? insets.top - 5 : insets.top;
const headerHeight = getDefaultHeaderHeight(layout, false, statusBarHeight);
-
const styles = createStyles({theme, insets, headerHeight});
const headerStyle = chatSessionStore?.shouldShowHeaderDivider
@@ -43,8 +47,11 @@ export const ChatHeader: React.FC = observer(() => {
-
-
+
+
);
diff --git a/src/components/ChatHeader/__tests__/ChatHeader.test.tsx b/src/components/ChatHeader/__tests__/ChatHeader.test.tsx
index 2f3a54e9..272a698d 100644
--- a/src/components/ChatHeader/__tests__/ChatHeader.test.tsx
+++ b/src/components/ChatHeader/__tests__/ChatHeader.test.tsx
@@ -10,11 +10,13 @@ jest.mock('../../HeaderLeft', () => ({
},
}));
+const mockHeaderRight = jest.fn((_props: object) => {
+ const {View} = require('react-native');
+ return ;
+});
+
jest.mock('../../HeaderRight', () => ({
- HeaderRight: () => {
- const {View} = require('react-native');
- return ;
- },
+ HeaderRight: (props: object) => mockHeaderRight(props),
}));
jest.mock('../../ChatHeaderTitle', () => ({
@@ -24,11 +26,13 @@ jest.mock('../../ChatHeaderTitle', () => ({
},
}));
-jest.mock('../../EnterpriseQuickPanel', () => ({
- EnterpriseQuickPanel: () => {
- const {View} = require('react-native');
- return ;
- },
+const mockFortyTwoQuickPanel = jest.fn((_props: object) => {
+ const {View} = require('react-native');
+ return ;
+});
+
+jest.mock('../../FortyTwoQuickPanel', () => ({
+ FortyTwoQuickPanel: (props: object) => mockFortyTwoQuickPanel(props),
}));
// Create a mock store object
@@ -55,7 +59,27 @@ describe('ChatHeader', () => {
expect(getByTestId('header-left')).toBeTruthy();
expect(getByTestId('header-right')).toBeTruthy();
expect(getByTestId('chat-header-title')).toBeTruthy();
- expect(getByTestId('enterprise-quick-panel')).toBeTruthy();
+ expect(getByTestId('forty-two-quick-panel')).toBeTruthy();
+ });
+
+ it('forwards quick-control callbacks to the Forty-Two panel', () => {
+ const onOpenModelPalPicker = jest.fn();
+ const onOpenGenerationSettings = jest.fn();
+
+ render(
+ ,
+ );
+
+ expect(mockFortyTwoQuickPanel).toHaveBeenLastCalledWith({
+ onOpenModelPalPicker,
+ onOpenGenerationSettings,
+ });
+ expect(mockHeaderRight).toHaveBeenLastCalledWith({
+ onOpenGenerationSettings,
+ });
});
it('applies correct styles when header divider should not be shown', () => {
diff --git a/src/components/ChatHeader/styles.ts b/src/components/ChatHeader/styles.ts
index dfd91269..36b049cb 100644
--- a/src/components/ChatHeader/styles.ts
+++ b/src/components/ChatHeader/styles.ts
@@ -15,6 +15,7 @@ export const createStyles = ({
container: {
height: headerHeight,
paddingTop: insets.top,
+ paddingHorizontal: 8,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
@@ -35,8 +36,8 @@ export const createStyles = ({
flexShrink: 0,
},
menuIcon: {
- height: 40,
- width: 40,
+ height: 44,
+ width: 44,
justifyContent: 'center',
alignItems: 'center',
},
@@ -44,9 +45,14 @@ export const createStyles = ({
elevation: 0,
shadowOpacity: 0,
borderBottomWidth: 0,
- backgroundColor: theme.colors.background,
+ borderBottomColor: 'transparent',
+ backgroundColor: theme.colors.surface,
},
headerWithDivider: {
- backgroundColor: theme.colors.background,
+ elevation: 0,
+ shadowOpacity: 0,
+ borderBottomWidth: 1,
+ borderBottomColor: theme.colors.outline,
+ backgroundColor: theme.colors.surface,
},
});
diff --git a/src/components/ChatHeaderTitle/ChatHeaderTitle.tsx b/src/components/ChatHeaderTitle/ChatHeaderTitle.tsx
index 8a2c2c2b..7c2a8645 100644
--- a/src/components/ChatHeaderTitle/ChatHeaderTitle.tsx
+++ b/src/components/ChatHeaderTitle/ChatHeaderTitle.tsx
@@ -17,9 +17,9 @@ const backendLabel = {
idle: 'pronto para carregar',
remote: 'remoto',
cpu: 'CPU',
- gpu: 'GPU',
- hybrid: 'CPU + GPU',
- unverified: 'acelerador não verificado',
+ gpu: 'Vulkan',
+ hybrid: 'CPU + Vulkan',
+ unverified: 'aceleração não verificada',
} as const;
export const ChatHeaderTitle: React.FC = observer(() => {
@@ -38,7 +38,17 @@ export const ChatHeaderTitle: React.FC = observer(() => {
const defaultTitle = l10n.components.chatHeaderTitle.defaultTitle;
const title = activePal?.name || activeSession?.title || defaultTitle;
const modelLabel = activeModel?.name ?? 'Nenhum modelo carregado';
- const executionLabel = backendLabel[enterpriseRuntimeStore.effectiveBackend];
+ const effectiveBackend = enterpriseRuntimeStore.effectiveBackend;
+ const executionLabel = backendLabel[effectiveBackend];
+
+ const statusColor =
+ effectiveBackend === 'gpu' || effectiveBackend === 'hybrid'
+ ? theme.colors.tertiary
+ : effectiveBackend === 'cpu'
+ ? theme.colors.secondary
+ : effectiveBackend === 'unverified'
+ ? theme.colors.error
+ : theme.colors.outlineVariant;
return (
@@ -46,9 +56,12 @@ export const ChatHeaderTitle: React.FC = observer(() => {
{title}
-
+
+
+ {modelLabel}
+
- {modelLabel} · {executionLabel}
+ · {executionLabel}
diff --git a/src/components/ChatHeaderTitle/styles.ts b/src/components/ChatHeaderTitle/styles.ts
index e4f21f1b..d3e1e0c2 100644
--- a/src/components/ChatHeaderTitle/styles.ts
+++ b/src/components/ChatHeaderTitle/styles.ts
@@ -18,11 +18,10 @@ export const createStyles = (theme: Theme) =>
gap: 6,
minWidth: 0,
},
- onlineDot: {
+ statusDot: {
width: 6,
height: 6,
borderRadius: 3,
- backgroundColor: theme.colors.primary,
},
subtitle: {
flexShrink: 1,
diff --git a/src/components/ChatInput/styles.ts b/src/components/ChatInput/styles.ts
index 2415df7f..0e739304 100644
--- a/src/components/ChatInput/styles.ts
+++ b/src/components/ChatInput/styles.ts
@@ -15,18 +15,18 @@ export const createStyles = ({
flexDirection: 'column',
},
palBtn: {
- height: 28,
- width: 28,
+ height: 30,
+ width: 30,
alignItems: 'center',
justifyContent: 'center',
- borderRadius: 100,
+ borderRadius: 15,
},
plusButton: {
- height: 28,
- width: 28,
+ height: 30,
+ width: 30,
alignItems: 'center',
justifyContent: 'center',
- borderRadius: 100,
+ borderRadius: 15,
opacity: 0.9,
},
thinkingToggle: {
@@ -42,11 +42,11 @@ export const createStyles = ({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
- borderRadius: 16,
+ borderRadius: 14,
borderWidth: 1,
paddingHorizontal: 8,
paddingVertical: 4,
- marginLeft: 8,
+ marginLeft: 6,
},
thinkingToggleLeftDisabled: {
backgroundColor: 'transparent',
@@ -62,19 +62,19 @@ export const createStyles = ({
palSelector: {
flexDirection: 'row',
alignItems: 'center',
- gap: 4,
+ gap: 6,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'flex-start',
- gap: 13,
+ gap: 10,
flexShrink: 1,
},
input: {
...theme.fonts.inputTextStyle,
color: theme.colors.inverseOnSurface,
flex: 1,
- maxHeight: 150,
+ maxHeight: 160,
paddingVertical: 0,
},
marginRight: {
@@ -83,27 +83,29 @@ export const createStyles = ({
inputContainer: {
flex: 1,
flexDirection: 'column',
- borderRadius: 12,
+ borderRadius: 24,
overflow: 'hidden',
},
textInputArea: {
flex: 1,
- paddingHorizontal: 24,
- paddingTop: 20,
- paddingBottom: 8,
+ paddingHorizontal: 18,
+ paddingTop: 16,
+ paddingBottom: 6,
},
controlBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
- paddingHorizontal: 24,
- paddingVertical: 10,
- minHeight: 36,
+ paddingHorizontal: 14,
+ paddingVertical: 8,
+ minHeight: 44,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: theme.colors.outlineVariant,
},
leftControls: {
flexDirection: 'row',
alignItems: 'center',
- gap: 10,
+ gap: 8,
flex: 1,
},
rightControls: {
@@ -121,8 +123,8 @@ export const createStyles = ({
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 12,
- borderTopLeftRadius: 12,
- borderTopRightRadius: 12,
+ borderTopLeftRadius: 24,
+ borderTopRightRadius: 24,
borderBottomWidth: 1,
borderBottomColor: theme.colors.outlineVariant,
zIndex: 10, // Ensure edit bar stays above other elements
@@ -137,8 +139,8 @@ export const createStyles = ({
flex: 1,
flexDirection: 'row',
alignItems: 'flex-end',
- paddingHorizontal: 24,
- paddingVertical: 20,
+ paddingHorizontal: 18,
+ paddingVertical: 16,
marginTop: isEditMode ? 28 : 0,
},
palNameWrapper: {
@@ -153,12 +155,12 @@ export const createStyles = ({
},
// New compact pal name styles for control bar
palNameCompact: {
- fontSize: 10,
+ fontSize: 11,
...fontStyles.regular,
color: theme.colors.inverseOnSurface,
},
palNameValueCompact: {
- fontSize: 10,
+ fontSize: 11,
...fontStyles.semibold,
color: theme.colors.inverseOnSurface,
},
diff --git a/src/components/ChatView/ChatView.tsx b/src/components/ChatView/ChatView.tsx
index 5602f541..32df9730 100644
--- a/src/components/ChatView/ChatView.tsx
+++ b/src/components/ChatView/ChatView.tsx
@@ -75,6 +75,7 @@ import {
Menu,
PendingIndicator,
ChatPalModelPickerSheet,
+ ChatGenerationSettingsSheet,
ChatHeader,
ChatEmptyPlaceholder,
VideoPalEmptyPlaceholder,
@@ -256,6 +257,8 @@ export const ChatView = observer(
inputTextRef.current = inputText;
const [inputImages, setInputImages] = React.useState([]);
const [isPickerVisible, setIsPickerVisible] = React.useState(false);
+ const [isGenerationSettingsVisible, setIsGenerationSettingsVisible] =
+ React.useState(false);
const [_selectedModel, setSelectedModel] = React.useState(
null,
);
@@ -1126,7 +1129,12 @@ export const ChatView = observer(
onLayout={onLayout}>
{/* Header */}
-
+ setIsPickerVisible(true)}
+ onOpenGenerationSettings={() =>
+ setIsGenerationSettingsVisible(true)
+ }
+ />
{/* Main chat container */}
@@ -1215,6 +1223,11 @@ export const ChatView = observer(
chatInputHeight={chatInputHeight.height}
/>
)}
+
+ setIsGenerationSettingsVisible(false)}
+ />
{/* Image viewer */}
diff --git a/src/components/ChatView/styles.ts b/src/components/ChatView/styles.ts
index 9b18c712..2c4b2abd 100644
--- a/src/components/ChatView/styles.ts
+++ b/src/components/ChatView/styles.ts
@@ -8,10 +8,10 @@ export const createStyles = ({theme}: {theme: Theme}) =>
},
flatList: {
height: '100%',
- // flex: 1,
},
flatListContentContainer: {
flexGrow: 1,
+ paddingHorizontal: 6,
},
footer: {
height: 16,
@@ -32,8 +32,8 @@ export const createStyles = ({theme}: {theme: Theme}) =>
position: 'absolute',
right: 16,
backgroundColor: theme.colors.primary,
- width: 35,
- height: 35,
+ width: 40,
+ height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
@@ -54,12 +54,14 @@ export const createStyles = ({theme}: {theme: Theme}) =>
backgroundColor: 'transparent',
},
inputContainer: {
- borderTopLeftRadius: theme.borders.inputBorderRadius,
- borderTopRightRadius: theme.borders.inputBorderRadius,
+ borderRadius: 24,
+ borderWidth: 1,
+ borderColor: theme.colors.outline,
+ overflow: 'hidden',
position: 'absolute',
zIndex: 10,
- left: 0,
- right: 0,
+ left: 12,
+ right: 12,
bottom: 0,
...(!theme.dark
? {
diff --git a/src/components/FortyTwoQuickPanel/FortyTwoQuickPanel.tsx b/src/components/FortyTwoQuickPanel/FortyTwoQuickPanel.tsx
new file mode 100644
index 00000000..2b8215c1
--- /dev/null
+++ b/src/components/FortyTwoQuickPanel/FortyTwoQuickPanel.tsx
@@ -0,0 +1,533 @@
+import React from 'react';
+import {
+ Modal,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ useWindowDimensions,
+ View,
+} from 'react-native';
+import {observer} from 'mobx-react';
+import {Button, IconButton, Text} from 'react-native-paper';
+import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
+import {useSafeAreaInsets} from 'react-native-safe-area-context';
+
+import {useTheme} from '../../hooks';
+import {
+ chatSessionStore,
+ enterpriseRuntimeStore,
+ type EnterpriseEffectiveBackend,
+ type EnterpriseRequestedBackend,
+ modelStore,
+ palStore,
+} from '../../store';
+import {ModelOrigin, type Theme} from '../../utils/types';
+
+interface Props {
+ onOpenModelPalPicker?: () => void;
+ onOpenGenerationSettings?: () => void;
+}
+
+const requestedLabels: Record = {
+ auto: 'Automático',
+ cpu: 'CPU',
+ gpu: 'Vulkan',
+ hybrid: 'Híbrido',
+};
+
+const effectiveLabels: Record = {
+ idle: 'Sem modelo',
+ remote: 'Remoto',
+ cpu: 'CPU',
+ gpu: 'Vulkan',
+ hybrid: 'CPU + Vulkan',
+ unverified: 'Aceleração não verificada',
+};
+
+const options: Array<{
+ id: EnterpriseRequestedBackend;
+ icon: string;
+ description: string;
+}> = [
+ {
+ id: 'auto',
+ icon: 'auto-fix',
+ description: 'Escolhe o caminho seguro disponível para este modelo.',
+ },
+ {
+ id: 'cpu',
+ icon: 'memory',
+ description: 'Fallback ARM estável e previsível.',
+ },
+ {
+ id: 'gpu',
+ icon: 'expansion-card-variant',
+ description:
+ 'Solicita offload Vulkan quando o runtime realmente expõe a GPU.',
+ },
+ {
+ id: 'hybrid',
+ icon: 'swap-horizontal-bold',
+ description: 'Combina CPU e GPU quando a divisão é suportada.',
+ },
+];
+
+const statusColor = (backend: EnterpriseEffectiveBackend, theme: Theme) => {
+ switch (backend) {
+ case 'gpu':
+ case 'hybrid':
+ return theme.colors.tertiary;
+ case 'cpu':
+ return theme.colors.secondary;
+ case 'remote':
+ return theme.colors.primary;
+ case 'unverified':
+ return theme.colors.error;
+ default:
+ return theme.colors.outlineVariant;
+ }
+};
+
+export const FortyTwoQuickPanel = observer((props: Props) => {
+ const {onOpenModelPalPicker, onOpenGenerationSettings} = props;
+ const theme = useTheme();
+ const insets = useSafeAreaInsets();
+ const {width, height} = useWindowDimensions();
+ const [visible, setVisible] = React.useState(false);
+ const [isApplying, setIsApplying] = React.useState(false);
+
+ React.useEffect(() => {
+ enterpriseRuntimeStore.refresh().catch(() => {
+ // Diagnostics stay in store state; chat must remain available.
+ });
+ }, []);
+
+ const styles = React.useMemo(
+ () => createStyles(theme, width, height, insets.top, insets.bottom),
+ [theme, width, height, insets.top, insets.bottom],
+ );
+
+ const activeModel = modelStore.activeModel;
+ const activePal = chatSessionStore.activePalId
+ ? palStore.pals.find(pal => pal.id === chatSessionStore.activePalId)
+ : undefined;
+ const isRemoteModel = activeModel?.origin === ModelOrigin.REMOTE;
+ const requested = enterpriseRuntimeStore.requestedBackend;
+ const effective = enterpriseRuntimeStore.effectiveBackend;
+ const color = statusColor(effective, theme);
+ const compact = width < 760;
+
+ const applyRequestedBackend = async () => {
+ if (!activeModel || isRemoteModel) {
+ return;
+ }
+
+ setIsApplying(true);
+ try {
+ await modelStore.releaseContext();
+ await modelStore.initContext(activeModel);
+ await enterpriseRuntimeStore.refresh();
+ } catch (error) {
+ console.error('[FortyTwoQuickPanel] Failed to reload model:', error);
+ } finally {
+ setIsApplying(false);
+ }
+ };
+
+ const physicalGpu =
+ enterpriseRuntimeStore.gpuInfo?.renderer || 'Não identificada';
+ const runtimeGpu =
+ enterpriseRuntimeStore.gpuBackendDevice?.deviceName ||
+ 'Não exposta pelo runtime';
+ const effectiveLayers = enterpriseRuntimeStore.effectiveGpuLayers;
+
+ const closeAndOpen = (callback?: () => void) => {
+ setVisible(false);
+ callback?.();
+ };
+
+ return (
+ <>
+ setVisible(true)}
+ style={({pressed}) => [styles.pill, pressed && styles.pressed]}>
+
+ {!compact ? (
+
+ {effectiveLabels[effective]}
+
+ ) : null}
+
+
+
+ setVisible(false)}>
+ setVisible(false)}>
+ event.stopPropagation()}>
+
+
+ Forty-Two AI
+
+ Painel rápido · execução local
+
+
+ setVisible(false)}
+ />
+
+
+
+
+
+ MODELO ATIVO
+
+
+ {activeModel?.name ?? 'Nenhum modelo carregado'}
+
+
+ Pal: {activePal?.name ?? 'Nenhum Pal ativo'}
+
+
+
+
+
+ Efetivo: {effectiveLabels[effective]}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Execução
+
+ Solicitado e efetivo permanecem separados. A interface não
+ chama uma GPU de ativa só porque ela foi detectada.
+
+
+
+
+ {options.map(option => {
+ const selected = requested === option.id;
+ const needsGpu =
+ option.id === 'gpu' || option.id === 'hybrid';
+ const disabled =
+ needsGpu && !enterpriseRuntimeStore.gpuBackendAvailable;
+
+ return (
+
+ enterpriseRuntimeStore.setRequestedBackend(option.id)
+ }
+ style={({pressed}) => [
+ styles.option,
+ selected && styles.optionSelected,
+ disabled && styles.disabled,
+ pressed && !disabled && styles.pressed,
+ ]}>
+
+
+ {requestedLabels[option.id]}
+
+
+ {option.description}
+
+
+ );
+ })}
+
+
+
+
+
+ Diagnóstico rápido
+
+
+
+
+
+
+
+
+ {enterpriseRuntimeStore.requiresModelReload ? (
+
+
+
+ O perfil solicitado mudou. O modelo atual ainda usa a
+ configuração anterior.
+
+
+ ) : null}
+
+ {enterpriseRuntimeStore.lastError ? (
+
+ {enterpriseRuntimeStore.lastError}
+
+ ) : null}
+
+
+
+
+
+
+
+
+ >
+ );
+});
+
+const Metric = ({label, value}: {label: string; value: string | number}) => (
+
+
+ {label}
+
+
+ {String(value)}
+
+
+);
+
+const Diagnostic = ({label, value}: {label: string; value: string}) => (
+
+
+ {label}
+
+
+ {value}
+
+
+);
+
+const metricStyles = StyleSheet.create({
+ item: {flex: 1, minWidth: 0, gap: 3},
+ label: {opacity: 0.62},
+ diagnosticRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ gap: 16,
+ },
+ value: {flex: 1, textAlign: 'right'},
+});
+
+const createStyles = (
+ theme: Theme,
+ width: number,
+ height: number,
+ insetTop: number,
+ insetBottom: number,
+) => {
+ const panelWidth = Math.min(
+ 500,
+ Math.max(330, width >= 900 ? width * 0.38 : width * 0.9),
+ );
+
+ return StyleSheet.create({
+ pill: {
+ minHeight: 40,
+ maxWidth: 220,
+ paddingHorizontal: 12,
+ borderRadius: 20,
+ borderWidth: 1,
+ borderColor: theme.colors.outlineVariant,
+ backgroundColor: theme.colors.surface,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 8,
+ },
+ pillText: {color: theme.colors.onSurfaceVariant, flexShrink: 1},
+ dot: {width: 8, height: 8, borderRadius: 4},
+ pressed: {opacity: 0.72},
+ disabled: {opacity: 0.38},
+ backdrop: {
+ width,
+ height,
+ backgroundColor: 'rgba(0, 0, 0, 0.72)',
+ alignItems: 'flex-end',
+ },
+ panel: {
+ width: panelWidth,
+ height,
+ paddingTop: insetTop,
+ paddingBottom: insetBottom,
+ backgroundColor: theme.colors.background,
+ borderTopLeftRadius: 28,
+ borderBottomLeftRadius: 28,
+ borderLeftWidth: 1,
+ borderColor: theme.colors.outlineVariant,
+ overflow: 'hidden',
+ },
+ panelHeader: {
+ minHeight: 76,
+ paddingLeft: 22,
+ paddingRight: 10,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ borderBottomWidth: 1,
+ borderColor: theme.colors.outlineVariant,
+ },
+ titleBlock: {flexShrink: 1},
+ content: {padding: 20, gap: 18, paddingBottom: 40},
+ muted: {color: theme.colors.onSurfaceVariant},
+ eyebrow: {
+ color: theme.colors.tertiary,
+ letterSpacing: 1.1,
+ },
+ heroCard: {
+ padding: 18,
+ gap: 12,
+ borderRadius: 22,
+ borderWidth: 1,
+ borderColor: theme.colors.outlineVariant,
+ backgroundColor: theme.colors.surface,
+ },
+ effectiveBadge: {
+ alignSelf: 'flex-start',
+ minHeight: 32,
+ paddingHorizontal: 11,
+ borderRadius: 16,
+ backgroundColor: theme.colors.surfaceVariant,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ },
+ metrics: {flexDirection: 'row', gap: 14},
+ quickActions: {gap: 8},
+ section: {gap: 4},
+ grid: {flexDirection: 'row', flexWrap: 'wrap', gap: 10},
+ option: {
+ width: '48%',
+ minHeight: 128,
+ padding: 14,
+ borderRadius: 18,
+ borderWidth: 1,
+ borderColor: theme.colors.outlineVariant,
+ backgroundColor: theme.colors.surface,
+ gap: 7,
+ },
+ optionSelected: {
+ borderColor: theme.colors.tertiary,
+ backgroundColor: theme.colors.primaryContainer,
+ },
+ diagnostics: {
+ padding: 18,
+ gap: 12,
+ borderRadius: 20,
+ borderWidth: 1,
+ borderColor: theme.colors.outlineVariant,
+ backgroundColor: theme.colors.surface,
+ },
+ diagnosticTitle: {flexDirection: 'row', alignItems: 'center', gap: 8},
+ notice: {
+ padding: 14,
+ borderRadius: 16,
+ backgroundColor: theme.colors.tertiaryContainer,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ },
+ noticeText: {flex: 1, color: theme.colors.onTertiaryContainer},
+ error: {color: theme.colors.error},
+ });
+};
diff --git a/src/components/FortyTwoQuickPanel/index.ts b/src/components/FortyTwoQuickPanel/index.ts
new file mode 100644
index 00000000..e05e74b3
--- /dev/null
+++ b/src/components/FortyTwoQuickPanel/index.ts
@@ -0,0 +1 @@
+export * from './FortyTwoQuickPanel';
diff --git a/src/components/HeaderRight/HeaderRight.tsx b/src/components/HeaderRight/HeaderRight.tsx
index 1c3b5062..dce9c10d 100644
--- a/src/components/HeaderRight/HeaderRight.tsx
+++ b/src/components/HeaderRight/HeaderRight.tsx
@@ -33,7 +33,12 @@ import {
import {RenameModal, UsageStats} from '..';
import {ChatGenerationSettingsSheet} from '..';
-export const HeaderRight: React.FC = observer(() => {
+interface Props {
+ onOpenGenerationSettings?: () => void;
+}
+
+export const HeaderRight = observer((props: Props) => {
+ const {onOpenGenerationSettings} = props;
const theme = useTheme();
const [menuVisible, setMenuVisible] = React.useState(false);
const [renameModalVisible, setRenameModalVisible] = React.useState(false);
@@ -61,7 +66,11 @@ export const HeaderRight: React.FC = observer(() => {
};
const onPressGenerationSettings = () => {
- setChatGenerationSettingsVisible(true);
+ if (onOpenGenerationSettings) {
+ onOpenGenerationSettings();
+ } else {
+ setChatGenerationSettingsVisible(true);
+ }
closeMenu();
};
@@ -253,10 +262,12 @@ export const HeaderRight: React.FC = observer(() => {
leadingIcon={() => }
/>
- setChatGenerationSettingsVisible(false)}
- />
+ {!onOpenGenerationSettings ? (
+ setChatGenerationSettingsVisible(false)}
+ />
+ ) : null}
{session && (
{
expect(getByTestId('chat-generation-settings-sheet')).toBeTruthy();
});
+ it('routes generation settings through an external quick control', async () => {
+ const onOpenGenerationSettings = jest.fn();
+ const {getByTestId, findByText, queryByTestId} = renderWithI18n(
+ ,
+ );
+ fireEvent.press(getByTestId('menu-button'));
+
+ fireEvent.press(
+ await findByText(l10n.en.components.headerRight.generationSettings),
+ );
+
+ expect(onOpenGenerationSettings).toHaveBeenCalledTimes(1);
+ expect(queryByTestId('chat-generation-settings-sheet')).toBeNull();
+ });
+
it('handles delete action with confirmation', async () => {
const {getByTestId, findByText} = renderWithI18n();
const menuButton = getByTestId('menu-button');
diff --git a/src/store/EnterpriseRuntimeStore.ts b/src/store/EnterpriseRuntimeStore.ts
index 45a80510..960f34d1 100644
--- a/src/store/EnterpriseRuntimeStore.ts
+++ b/src/store/EnterpriseRuntimeStore.ts
@@ -19,7 +19,8 @@ export type EnterpriseEffectiveBackend =
| 'unverified';
const getModelStore = () =>
- require('./ModelStore').modelStore as typeof import('./ModelStore').modelStore;
+ require('./ModelStore')
+ .modelStore as typeof import('./ModelStore').modelStore;
const parseVulkanInfo = (json: string): VulkanInfo => {
const parsed: unknown = JSON.parse(json);