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);