From 01105266229d66bf53c1b5e2c4381b5a7e27c06b Mon Sep 17 00:00:00 2001 From: duffyduck Date: Fri, 17 Jul 2026 21:44:32 +0200 Subject: [PATCH] feat(app): Workbench-Dock statt Zoom-Landkarte + bedienbare VNC-Konsole (0.2.2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cockpit neu gedacht — das Pinch-Zoom-Landkarten-Konzept war auf 5 Zoll fummelig: - WorkspaceDeck + WorkspaceDock: Taskleiste unten (Chat · Code · Desktop), Ein-Tap-Panelwechsel im Daumenbereich, animierter Indikator, Aktivitaets- Badges (Code blau / Desktop gruen), Safe-Area, blendet bei offener Tastatur aus. Panels bildschirmfuellend + immer gemountet (kein Remount). - noVNC bedienbar: novncHtml bekommt Tastatur-Bridge (verstecktes Input-Feld → rfb.sendKey inkl. Enter/Backspace/Pfeile), Strg-Alt-Entf, Fit↔1:1. VncTile zeigt eine Steuerungs-Leiste (⌨ / Strg+Alt+Entf / ⤢). - Entfernt: WorkspaceCanvas, Tile, PreviewTile, CockpitOverviewButton, cockpitNav (Pinch-Canvas + Uebersichts-Button obsolet). layout.ts auf Panel-Metadaten geschlankt. Header nur noch mit Kompakt/Cockpit-Umschalter. tsc clean. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 19 ++ README.md | 5 +- android/App.tsx | 2 - .../src/components/CockpitOverviewButton.tsx | 48 ---- android/src/services/cockpitNav.ts | 43 ---- android/src/workspace/Tile.tsx | 61 ----- android/src/workspace/WorkspaceCanvas.tsx | 232 ------------------ android/src/workspace/WorkspaceDeck.tsx | 90 +++++++ android/src/workspace/WorkspaceDock.tsx | 108 ++++++++ android/src/workspace/WorkspaceScreen.tsx | 41 ++-- android/src/workspace/assets/novncHtml.ts | 30 ++- android/src/workspace/layout.ts | 87 +------ android/src/workspace/tiles/PreviewTile.tsx | 40 --- android/src/workspace/tiles/VncTile.tsx | 62 ++++- 14 files changed, 317 insertions(+), 551 deletions(-) delete mode 100644 android/src/components/CockpitOverviewButton.tsx delete mode 100644 android/src/services/cockpitNav.ts delete mode 100644 android/src/workspace/Tile.tsx delete mode 100644 android/src/workspace/WorkspaceCanvas.tsx create mode 100644 android/src/workspace/WorkspaceDeck.tsx create mode 100644 android/src/workspace/WorkspaceDock.tsx delete mode 100644 android/src/workspace/tiles/PreviewTile.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 78348e8..85f24bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,25 @@ Alle Änderungen am Projekt. Format: [Keep a Changelog](https://keepachangelog.c --- +## [0.2.2.0] — 2026-07-17 — Workbench: Taskleisten-Dock statt Zoom-Landkarte + bedienbare VNC-Konsole + +### Geändert + +**Cockpit-Navigation neu gedacht — Dock statt Pinch-Canvas** +- Die Zoom-Landkarte (mit 2 Fingern rauszoomen, Kachel finden, reintippen) fühlte sich auf 5 Zoll fummelig an. Ersetzt durch eine **Taskleiste unten im Daumenbereich** (Chat · Code · Desktop): **ein Tap wechselt sofort** das Panel, ein animierter Indikator gleitet unter das aktive Icon. Desktop-*Umfang*, Handy-*Ergonomie*. +- Jedes Panel ist **bildschirmfüllend** und Handy-optimiert; alle bleiben gemountet (nur das aktive ist sichtbar) → kein Remount, Chat behält RVS/Audio/Queue, WebViews ihren Zustand. +- Das Dock **blendet bei offener Tastatur aus** (mehr Platz zum Tippen) und respektiert die Gesten-Navigationsleiste (Safe-Area). +- **Aktivitäts-Punkte** am Dock: Code blau, sobald Dateien da sind; Desktop grün, wenn die VM verbunden ist. +- Entfällt: Pinch/Pan-Canvas, Übersichts-Button im Header, die „Landkarten"-Thumbnails. + +**noVNC-Konsole endlich bedienbar** +- Steuerungs-Leiste im Desktop-Panel: **⌨ Tastatur** (blendet die Handy-Tastatur ein, tippt direkt in die VM — inkl. Enter/Backspace/Pfeiltasten), **Strg+Alt+Entf**, und **⤢ Fit ↔ 1:1**. Tippen/Ziehen steuert weiterhin die Maus. + +### Deploy +Nur **App neu bauen** (kein Backend). APK 0.2.2.0. + +--- + ## [0.2.1.9] — 2026-07-17 — Kompakt ↔ Cockpit: Umschalter für den Kachel-Desktop ### Hinzugefügt diff --git a/README.md b/README.md index 58a3614..1f9a803 100644 --- a/README.md +++ b/README.md @@ -500,7 +500,7 @@ Erreichbar unter `http://:3001`. Teilt das Netzwerk mit der Bridge. ### Features -- **Desktop-Workspace (zoombarer Canvas)**: Die App ist eine desktop-artige Arbeitsfläche — rausgezoomt eine **Landkarte aus Kacheln** (Chat/Editor/Desktop/Vorschau), die man mit **2 Fingern zoomt und verschiebt**; Tippen auf eine Kachel zoomt sie voll auf und macht sie echt bedienbar („Übersicht + Fokus"). Reiner Chat verhält sich exakt wie bisher (eine Kachel, dauerhaft fokussiert). Zurück per „⤢ Übersicht" oder Hardware-Back. Basiert auf gesture-handler + reanimated; interaktive Inhalte werden nie unter Zoom-Transform gerendert (Keyboard/Touch bleiben korrekt) +- **Desktop-Workbench (Kompakt ↔ Cockpit)**: Umschalter oben rechts im Header. **Kompakt** = klassischer Vollbild-Chat (Default). **Cockpit** = Workbench mit **Taskleiste unten** (Chat · Code · Desktop) — ein Daumen-Tap wechselt sofort das Panel, animierter Indikator, Aktivitäts-Punkte (Code blau / Desktop grün). Jedes Panel bildschirmfüllend und Handy-optimiert; Dock blendet bei offener Tastatur aus. Desktop-Umfang, aber auf 5 Zoll bedienbar - **Live-Code-Editor** (Code-Projekte): Wird ein Projekt zum Code-Projekt (`set_project_kind('code')`), zeigt eine Editor-Kachel **live, was ARIA schreibt** (Syntax-Highlighting, offline) und du kannst selbst editieren → zurück an ARIA. ARIAs `Write`/`Edit` unter `/shared/projects//` werden im Proxy abgefangen und als `code_file` gespiegelt; deine Edits kommen als `code_file_edit` pfad-sicher zurück - **Live-Desktop per VNC (durch RVS getunnelt)**: ARIA baut/testet VMs mit **QEMU für jede Architektur** (x86/ARM/MIPS/PPC/RISC-V/SPARC, Host-Helper `aria-vm`, KVM für x86). Der QEMU-Desktop erscheint **live in der Desktop-Kachel** — noVNC in der WebView, RFB-Bytes werden als Base64 über RVS gebrückt (Bridge ↔ QEMU `127.0.0.1:5901`). Du bedienst die VM **live mit Maus/Tastatur**, NAT-sicher, kein offener Port am Host - Text-Chat mit ARIA @@ -1044,7 +1044,8 @@ docker exec aria-brain curl localhost:8080/memory/stats - [x] Background Audio Service: TTS, Wake-Word-Lauschen + Aufnahme laufen auch bei minimierter App weiter (Foreground-Service mit mediaPlayback|microphone, dynamische Notification) - [x] Disk-Voll Banner in Diagnostic mit copy-baren Cleanup-Befehlen - [x] Wake-Word on-device via openWakeWord (ONNX Runtime, kein API-Key) + State-Icon -- [x] **Desktop-Workspace**: zoom-/verschiebbarer Kachel-Canvas (gesture-handler + reanimated), „Übersicht + Fokus" — Chat/Editor/Desktop/Vorschau als Kacheln; reiner Chat unverändert (eine Kachel) +- [x] **Desktop-Workbench**: Taskleisten-Dock (Chat · Code · Desktop), Ein-Tap-Panelwechsel im Daumenbereich, Aktivitäts-Badges, keyboard-aware; Kompakt↔Cockpit-Umschalter. (Ersetzte den anfänglichen Pinch-Zoom-Kachel-Canvas — auf 5 Zoll zu fummelig) +- [x] **noVNC-Konsole bedienbar**: Tastatur-Einblendung (tippt in die VM), Strg+Alt+Entf, Fit↔1:1 - [x] **Live-Code-Editor** für Code-Projekte (WebView, offline, bidirektional) — ARIAs Write/Edit unter `/shared/projects//` live gespiegelt (`code_file`), eigene Edits zurück (`code_file_edit`) - [x] **QEMU für alle Architekturen** (Host-Helper `aria-vm`, `qemu-setup.sh`) + `set_project_kind`-Tool + Seed-Regel - [x] **VNC-Live-Desktop durch RVS getunnelt** (Bridge RFB-TCP ↔ RVS, noVNC-WebView mit WebSocket-Shim) — VM live mit Maus/Tastatur bedienbar diff --git a/android/App.tsx b/android/App.tsx index 99dddd7..2baf3f8 100644 --- a/android/App.tsx +++ b/android/App.tsx @@ -15,7 +15,6 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import WorkspaceScreen from './src/workspace/WorkspaceScreen'; import SettingsScreen from './src/screens/SettingsScreen'; import ViewModeToggle from './src/components/ViewModeToggle'; -import CockpitOverviewButton from './src/components/CockpitOverviewButton'; import rvs from './src/services/rvs'; import { initLogger, installGlobalCrashReporter } from './src/services/logger'; import { acquireBackgroundAudio } from './src/services/backgroundAudio'; @@ -172,7 +171,6 @@ const App: React.FC = () => { options={{ title: 'ARIA Chat', headerTitle: 'ARIA Cockpit', - headerLeft: () => , headerRight: () => , }} /> diff --git a/android/src/components/CockpitOverviewButton.tsx b/android/src/components/CockpitOverviewButton.tsx deleted file mode 100644 index 38e5ba0..0000000 --- a/android/src/components/CockpitOverviewButton.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/** - * CockpitOverviewButton — Header-Button (links) für „⤢ Übersicht". - * - * Nur sichtbar im Cockpit-Modus, wenn gerade eine Kachel fokussiert ist. Sitzt - * im Navigations-Header, kollidiert also mit nichts im Chat (früher hing er über - * dem Abbrechen-Button der „ARIA denkt"-Leiste). - */ - -import React, { useEffect, useState } from 'react'; -import { StyleSheet, Text, TouchableOpacity } from 'react-native'; -import cockpitNav from '../services/cockpitNav'; -import viewMode, { ViewModeValue } from '../services/viewMode'; - -const CockpitOverviewButton: React.FC = () => { - const [mode, setMode] = useState(viewMode.get()); - const [focused, setFocused] = useState(cockpitNav.isFocused()); - - useEffect(() => viewMode.subscribe(setMode), []); - useEffect(() => cockpitNav.subscribe(setFocused), []); - - if (mode !== 'cockpit' || !focused) return null; - - return ( - cockpitNav.requestOverview()} - style={styles.pill} - hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} - activeOpacity={0.75} - > - ⤢ Übersicht - - ); -}; - -const styles = StyleSheet.create({ - pill: { - marginLeft: 12, - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 16, - borderWidth: 1, - borderColor: '#0096FF', - backgroundColor: '#0A1F33', - }, - text: { color: '#0096FF', fontSize: 13, fontWeight: '700' }, -}); - -export default CockpitOverviewButton; diff --git a/android/src/services/cockpitNav.ts b/android/src/services/cockpitNav.ts deleted file mode 100644 index 315cafe..0000000 --- a/android/src/services/cockpitNav.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * cockpitNav — kleine Brücke zwischen WorkspaceCanvas (Fokus-Zustand) und dem - * Navigations-Header, damit der „⤢ Übersicht"-Button im Header sitzen kann - * (kollidiert dort mit nichts im Chat) statt im überfüllten Chat-Inhalt. - * - * Der Canvas meldet, ob gerade eine Kachel fokussiert ist, und registriert eine - * Aktion zum Zurück-zur-Übersicht. Der Header abonniert den Zustand und ruft die - * Aktion. Muster wie services/rvs.ts (Singleton). - */ - -type Sub = (focused: boolean) => void; - -class CockpitNav { - private focused = false; - private overviewHandler: (() => void) | null = null; - private subs: Sub[] = []; - - isFocused(): boolean { return this.focused; } - - setFocused(f: boolean): void { - if (this.focused === f) return; - this.focused = f; - this.subs.forEach((cb) => cb(f)); - } - - /** Canvas registriert seine „zur Übersicht"-Aktion (null beim Unmount). */ - registerOverview(fn: (() => void) | null): void { - this.overviewHandler = fn; - } - - requestOverview(): void { - this.overviewHandler?.(); - } - - subscribe(cb: Sub): () => void { - this.subs.push(cb); - cb(this.focused); - return () => { this.subs = this.subs.filter((s) => s !== cb); }; - } -} - -const cockpitNav = new CockpitNav(); -export default cockpitNav; diff --git a/android/src/workspace/Tile.tsx b/android/src/workspace/Tile.tsx deleted file mode 100644 index 8c9d0c6..0000000 --- a/android/src/workspace/Tile.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Tile — leichte Thumbnail-Darstellung einer Kachel in der gezoomten "Landkarte". - * - * Zeigt NUR Icon + Titel (+ optionalen Untertitel). Die schweren, interaktiven - * Inhalte (ChatScreen, WebViews) liegen NICHT hier, sondern in der separaten - * Identity-Content-Ebene des WorkspaceCanvas — Thumbnails werden nie skaliert - * interaktiv. Ein Tap fokussiert die Kachel (zoomt sie voll auf). - */ - -import React from 'react'; -import { StyleSheet, Text, View } from 'react-native'; -import { Gesture, GestureDetector } from 'react-native-gesture-handler'; -import { runOnJS } from 'react-native-reanimated'; -import { TileId, TileRect, TILE_META } from './layout'; - -interface Props { - id: TileId; - /** Welt-View-lokales Rect (relativ zur Bounds-Ecke). */ - rect: TileRect; - subtitle?: string; - onFocus: (id: TileId) => void; -} - -const Tile: React.FC = ({ id, rect, subtitle, onFocus }) => { - const meta = TILE_META[id]; - const tap = Gesture.Tap() - .maxDuration(300) - .onEnd((_e, success) => { - if (success) runOnJS(onFocus)(id); - }); - - return ( - - - {meta.icon} - {meta.title} - {!!subtitle && {subtitle}} - Tippen zum Öffnen - - - ); -}; - -const styles = StyleSheet.create({ - tile: { - position: 'absolute', - backgroundColor: '#12122A', - borderRadius: 32, - borderWidth: 3, - borderColor: '#1E1E2E', - alignItems: 'center', - justifyContent: 'center', - padding: 40, - }, - icon: { fontSize: 220, marginBottom: 24 }, - title: { color: '#FFFFFF', fontSize: 96, fontWeight: '800' }, - subtitle: { color: '#9090B0', fontSize: 52, marginTop: 20, textAlign: 'center' }, - hint: { color: '#555570', fontSize: 46, marginTop: 40 }, -}); - -export default Tile; diff --git a/android/src/workspace/WorkspaceCanvas.tsx b/android/src/workspace/WorkspaceCanvas.tsx deleted file mode 100644 index 54db78d..0000000 --- a/android/src/workspace/WorkspaceCanvas.tsx +++ /dev/null @@ -1,232 +0,0 @@ -/** - * WorkspaceCanvas — die zoom-/verschiebbare "Landkarte" plus Fokus-Modus. - * - * Zwei Ebenen uebereinander: - * 1. Welt-Ebene (skaliert/verschoben): nur leichte Thumbnail-Kacheln. Hier - * wirken 2-Finger-Pinch-Zoom + 2-Finger-Pan (nur in der Uebersicht). - * 2. Identity-Content-Ebene (Scale 1, nie transformiert): die schweren, - * interaktiven Inhalte (ChatScreen + WebViews). Alle sichtbaren Kacheln - * sind hier IMMER gemountet; nur die fokussierte ist per display sichtbar. - * Dadurch bleiben Touch-Koordinaten/Keyboard korrekt und nichts remountet - * beim Fokuswechsel. - * - * Tap auf eine Thumbnail-Kachel → Fokus (voll aufgezoomt + interaktiv). Der - * "⤢ Übersicht"-Button bzw. der Hardware-Back fuehren zurueck zur Landkarte. - * Bei nur einer Kachel (reiner Chat) ist diese dauerhaft fokussiert und der - * Canvas verhaelt sich exakt wie der bisherige Vollbild-Chat. - */ - -import React, { useEffect, useMemo, useState } from 'react'; -import { BackHandler, StyleSheet, Text, useWindowDimensions, View } from 'react-native'; -import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; -import { Gesture, GestureDetector } from 'react-native-gesture-handler'; - -import { - boundsOf, Camera, focusCamera, overviewCamera, TileId, TILE_RECTS, toLocal, -} from './layout'; -import cockpitNav from '../services/cockpitNav'; -import Tile from './Tile'; -import { useWorkspaceLayout } from './useWorkspaceLayout'; -import ChatTile from './tiles/ChatTile'; -import CodeEditorTile from './tiles/CodeEditorTile'; -import VncTile from './tiles/VncTile'; -import PreviewTile from './tiles/PreviewTile'; - -interface Props { - projectId: string; - visibleTiles: TileId[]; - subtitles?: Partial>; -} - -const ANIM = { duration: 260 }; -const MIN_SCALE = 0.15; -const MAX_SCALE = 4; - -const WorkspaceCanvas: React.FC = ({ projectId, visibleTiles, subtitles }) => { - const { width: vw, height: vh } = useWindowDimensions(); - const [focusedTileId, setFocusedTileId] = useState('chat'); - - const visibleKey = visibleTiles.join(','); - const bounds = useMemo(() => boundsOf(visibleTiles), [visibleKey]); - const worldW = bounds.w; - const worldH = bounds.h; - const { loaded: layoutLoaded, getFocus, saveFocus } = useWorkspaceLayout(projectId); - - // Kamera (shared values fuer 60fps auf dem UI-Thread). - const scale = useSharedValue(1); - const tx = useSharedValue(0); - const ty = useSharedValue(0); - const savedScale = useSharedValue(1); - const savedTx = useSharedValue(0); - const savedTy = useSharedValue(0); - - // Verschwindet die fokussierte Kachel aus der Sichtbarkeit → auf Chat zurueck. - useEffect(() => { - if (focusedTileId && !visibleTiles.includes(focusedTileId)) { - setFocusedTileId('chat'); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [visibleKey]); - - // Zuletzt fokussierte Kachel pro Projekt wiederherstellen. - useEffect(() => { - if (!layoutLoaded) return; - const stored = getFocus(); - if (stored === undefined) return; - if (stored === null || visibleTiles.includes(stored)) setFocusedTileId(stored); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectId, layoutLoaded, visibleKey]); - - // Fokus-Wahl persistieren. - useEffect(() => { - if (layoutLoaded) saveFocus(focusedTileId); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [focusedTileId, layoutLoaded]); - - // Kamera auf das Ziel fahren (Fokus-Rect oder Uebersicht). - useEffect(() => { - const cam: Camera = focusedTileId - ? focusCamera(toLocal(TILE_RECTS[focusedTileId], bounds), worldW, worldH, vw, vh) - : overviewCamera(worldW, worldH, vw, vh); - scale.value = withTiming(cam.scale, ANIM); - tx.value = withTiming(cam.tx, ANIM); - ty.value = withTiming(cam.ty, ANIM); - savedScale.value = cam.scale; - savedTx.value = cam.tx; - savedTy.value = cam.ty; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [focusedTileId, visibleKey, vw, vh]); - - // Fokus-Zustand + „zur Uebersicht"-Aktion an den Header (cockpitNav) melden, - // damit der Uebersicht-Button dort sitzt (kollidiert nicht mit dem Chat). - useEffect(() => { - cockpitNav.setFocused(focusedTileId != null); - cockpitNav.registerOverview(() => setFocusedTileId(null)); - return () => { cockpitNav.registerOverview(null); cockpitNav.setFocused(false); }; - }, [focusedTileId]); - - // Hardware-Back: im Fokus → zurueck zur Uebersicht. - useEffect(() => { - const onBack = () => { - if (focusedTileId) { - setFocusedTileId(null); - return true; - } - return false; - }; - const sub = BackHandler.addEventListener('hardwareBackPress', onBack); - return () => sub.remove(); - }, [focusedTileId]); - - // Gesten nur in der Uebersicht (Fokus-Modus: Touches fallen an die Kachel). - const gesturesEnabled = !focusedTileId; - const canvasGesture = useMemo(() => { - const pinch = Gesture.Pinch() - .enabled(gesturesEnabled) - .onUpdate((e) => { - 'worklet'; - scale.value = Math.max(MIN_SCALE, Math.min(MAX_SCALE, savedScale.value * e.scale)); - }) - .onEnd(() => { - 'worklet'; - savedScale.value = scale.value; - }); - const pan = Gesture.Pan() - .enabled(gesturesEnabled) - .minPointers(2) - .onUpdate((e) => { - 'worklet'; - tx.value = savedTx.value + e.translationX; - ty.value = savedTy.value + e.translationY; - }) - .onEnd(() => { - 'worklet'; - savedTx.value = tx.value; - savedTy.value = ty.value; - }); - return Gesture.Simultaneous(pinch, pan); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [gesturesEnabled]); - - const worldStyle = useAnimatedStyle(() => ({ - transform: [ - { translateX: tx.value }, - { translateY: ty.value }, - { scale: scale.value }, - ], - })); - - const renderContent = (id: TileId) => { - switch (id) { - case 'chat': return ; - case 'editor': return ; - case 'vnc': return ; - case 'preview': return ; - default: return null; - } - }; - - return ( - - {/* Ebene 1 — skalierte Landkarte mit Thumbnails */} - - - - {visibleTiles.map((id) => ( - - ))} - - - - - {/* Ebene 2 — Identity-Content, immer gemountet, nur fokussierte sichtbar */} - - {visibleTiles.map((id) => ( - - {renderContent(id)} - - ))} - - - {/* Steuerung — der „⤢ Übersicht"-Button sitzt im Header (cockpitNav), - nicht hier im Chat-Inhalt. */} - {!focusedTileId && ( - - Kachel antippen zum Öffnen · 2 Finger: zoomen & schieben - - )} - - ); -}; - -const styles = StyleSheet.create({ - root: { flex: 1, backgroundColor: '#0D0D1A' }, - world: { position: 'absolute', left: 0, top: 0 }, - hintBar: { - position: 'absolute', - bottom: 16, - left: 0, - right: 0, - alignItems: 'center', - }, - hintText: { - color: '#9090B0', - fontSize: 12, - backgroundColor: 'rgba(18,18,42,0.85)', - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 14, - overflow: 'hidden', - }, -}); - -export default WorkspaceCanvas; diff --git a/android/src/workspace/WorkspaceDeck.tsx b/android/src/workspace/WorkspaceDeck.tsx new file mode 100644 index 0000000..3349feb --- /dev/null +++ b/android/src/workspace/WorkspaceDeck.tsx @@ -0,0 +1,90 @@ +/** + * WorkspaceDeck — die Workbench: Vollbild-Panels + Taskleisten-Dock unten. + * + * Statt einer Zoom-Landkarte: jedes Panel ist bildschirmfuellend und Handy- + * optimiert, das Dock wechselt per Daumen-Tap sofort. Alle Panels sind IMMER + * gemountet (nur das aktive ist via display sichtbar) → kein Remount, Chat + * behaelt RVS/Audio/Queue, WebViews behalten ihren Zustand. + * + * Bei offener Tastatur blendet das Dock aus (mehr Platz zum Tippen). + */ + +import React, { useEffect, useState } from 'react'; +import { Keyboard, StyleSheet, View } from 'react-native'; +import { TileId } from './layout'; +import { useWorkspaceLayout } from './useWorkspaceLayout'; +import WorkspaceDock from './WorkspaceDock'; +import ChatTile from './tiles/ChatTile'; +import CodeEditorTile from './tiles/CodeEditorTile'; +import VncTile from './tiles/VncTile'; + +interface Props { + projectId: string; + panels: TileId[]; + badges?: Partial>; +} + +const WorkspaceDeck: React.FC = ({ projectId, panels, badges }) => { + const [active, setActive] = useState('chat'); + const [kbVisible, setKbVisible] = useState(false); + const { loaded, getFocus, saveFocus } = useWorkspaceLayout(projectId); + + // Aktives Panel pro Projekt wiederherstellen. + useEffect(() => { + if (!loaded) return; + const stored = getFocus(); + if (stored && panels.includes(stored)) setActive(stored); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectId, loaded]); + + // Falls das aktive Panel wegfaellt → erstes nehmen. + useEffect(() => { + if (!panels.includes(active)) setActive(panels[0] || 'chat'); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [panels.join(',')]); + + useEffect(() => { + if (loaded) saveFocus(active); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active, loaded]); + + useEffect(() => { + const s1 = Keyboard.addListener('keyboardDidShow', () => setKbVisible(true)); + const s2 = Keyboard.addListener('keyboardDidHide', () => setKbVisible(false)); + return () => { s1.remove(); s2.remove(); }; + }, []); + + const render = (id: TileId) => { + switch (id) { + case 'chat': return ; + case 'editor': return ; + case 'vnc': return ; + default: return null; + } + }; + + return ( + + + {panels.map((id) => ( + + {render(id)} + + ))} + + {!kbVisible && ( + + )} + + ); +}; + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: '#0D0D1A' }, + stack: { flex: 1, position: 'relative' }, +}); + +export default WorkspaceDeck; diff --git a/android/src/workspace/WorkspaceDock.tsx b/android/src/workspace/WorkspaceDock.tsx new file mode 100644 index 0000000..35f04a2 --- /dev/null +++ b/android/src/workspace/WorkspaceDock.tsx @@ -0,0 +1,108 @@ +/** + * WorkspaceDock — die Taskleiste unten (Daumenzone). Ein Tap wechselt sofort + * das Panel; ein animierter Indikator gleitet unter das aktive Icon. Kleine + * Aktivitaets-Punkte (Badges) zeigen z.B. „Desktop verbunden" / „Code da". + * + * Das ist der Kern des Workbench-Gefuehls: Desktop-Umfang, aber Handy-schnell + * per Daumen erreichbar — statt einer Zoom-Landkarte. + */ + +import React, { useState } from 'react'; +import { LayoutChangeEvent, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { TileId, TILE_META } from './layout'; + +interface Props { + panels: TileId[]; + active: TileId; + badges?: Partial>; // TileId → Punkt-Farbe (undefined = kein Punkt) + onSelect: (id: TileId) => void; +} + +const WorkspaceDock: React.FC = ({ panels, active, badges, onSelect }) => { + const insets = useSafeAreaInsets(); + const [rowW, setRowW] = useState(0); + const n = Math.max(1, panels.length); + const idx = Math.max(0, panels.indexOf(active)); + const slot = rowW / n; + + const onLayout = (e: LayoutChangeEvent) => setRowW(e.nativeEvent.layout.width); + + const indicatorStyle = useAnimatedStyle(() => ({ + width: slot, + transform: [{ translateX: withTiming(slot * idx, { duration: 200 }) }], + })); + + return ( + + + {rowW > 0 && } + {panels.map((id) => { + const meta = TILE_META[id]; + const isActive = id === active; + const badge = badges?.[id]; + return ( + onSelect(id)} + activeOpacity={0.7} + > + + {meta.icon} + {!!badge && } + + + {meta.title} + + + ); + })} + + + ); +}; + +const styles = StyleSheet.create({ + dock: { + backgroundColor: '#0B0B18', + borderTopWidth: 1, + borderTopColor: '#1E1E2E', + }, + row: { + flexDirection: 'row', + height: 58, + position: 'relative', + }, + indicator: { + position: 'absolute', + top: 0, + height: 3, + backgroundColor: '#0096FF', + borderBottomLeftRadius: 3, + borderBottomRightRadius: 3, + }, + item: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: 2, + }, + icon: { fontSize: 22, opacity: 0.55 }, + iconActive: { opacity: 1 }, + label: { color: '#6A6A85', fontSize: 10, fontWeight: '600' }, + labelActive: { color: '#0096FF' }, + badge: { + position: 'absolute', + top: -2, + right: -6, + width: 8, + height: 8, + borderRadius: 4, + borderWidth: 1, + borderColor: '#0B0B18', + }, +}); + +export default WorkspaceDock; diff --git a/android/src/workspace/WorkspaceScreen.tsx b/android/src/workspace/WorkspaceScreen.tsx index 72e5974..fcee944 100644 --- a/android/src/workspace/WorkspaceScreen.tsx +++ b/android/src/workspace/WorkspaceScreen.tsx @@ -1,13 +1,11 @@ /** - * WorkspaceScreen — Screen-Wrapper fuer den Workspace-Canvas. + * WorkspaceScreen — Screen-Wrapper fuer die Workbench. * - * Buendelt die Signale, die entscheiden welche Kacheln sichtbar sind: - * - projectFocus: aktives Projekt + dessen kind ('code'|'chat') - * - codeFile: kam schon eine Code-Datei rein? (Live-Reveal des Editors) - * - desktop: ist ein QEMU-Desktop verfuegbar? (Live-Reveal der VNC-Kachel) + * Kompakt-Modus → klassischer Vollbild-Chat (wie vor dem Umbau). + * Cockpit-Modus → Workbench mit Taskleisten-Dock: Chat · Code · Desktop. * - * Ersetzt den bisherigen Chat-Tab: die ChatScreen lebt als Kachel im Canvas, - * bleibt aber genau eine Instanz. + * Aktivitaets-Badges am Dock: Editor blau, wenn schon Code-Dateien da sind; + * Desktop gruen, wenn eine VM verbunden ist. */ import React, { useEffect, useMemo, useState } from 'react'; @@ -16,8 +14,10 @@ import codeFile from '../services/codeFile'; import desktop from '../services/desktop'; import viewMode, { ViewModeValue } from '../services/viewMode'; import ChatScreen from '../screens/ChatScreen'; -import { TileId, TILE_ORDER } from './layout'; -import WorkspaceCanvas from './WorkspaceCanvas'; +import { TileId } from './layout'; +import WorkspaceDeck from './WorkspaceDeck'; + +const COCKPIT_PANELS: TileId[] = ['chat', 'editor', 'vnc']; const WorkspaceScreen: React.FC = () => { const [mode, setMode] = useState(viewMode.get()); @@ -47,29 +47,18 @@ const WorkspaceScreen: React.FC = () => { return unsub; }, [pid, kind]); - // Im Cockpit immer alle Kacheln zeigen (Editor/Desktop/Vorschau als - // Platzhalter, bis ein Code-Projekt/VM aktiv ist) — sonst „fehlen" sie - // gefuehlt. isCode blendet nur die Untertitel-Info um. - const visibleTiles: TileId[] = TILE_ORDER; - const isCode = kind === 'code' || hasCode || hasDesktop; - - const subtitles = useMemo(() => { - const fileCount = codeFile.getFiles(pid).length; - return { - chat: pid ? projectFocus.getProjectName(pid) : 'Hauptchat', - editor: isCode ? `${fileCount} Datei${fileCount === 1 ? '' : 'en'}` : 'kein Code-Projekt', - vnc: hasDesktop ? 'verbunden' : 'kein Desktop', - preview: undefined, - } as Partial>; - }, [pid, focus.projectNameById, isCode, hasCode, hasDesktop]); + const badges = useMemo(() => ({ + editor: hasCode ? '#0096FF' : undefined, + vnc: hasDesktop ? '#34C759' : undefined, + } as Partial>), [hasCode, hasDesktop]); // Kompakt-Ansicht: klassischer Vollbild-Chat, exakt wie vor dem Umbau. if (mode === 'compact') { return ; } - // Cockpit: zoombarer Kachel-Desktop. - return ; + // Cockpit: Workbench mit Dock. + return ; }; export default WorkspaceScreen; diff --git a/android/src/workspace/assets/novncHtml.ts b/android/src/workspace/assets/novncHtml.ts index 3524510..275e008 100644 --- a/android/src/workspace/assets/novncHtml.ts +++ b/android/src/workspace/assets/novncHtml.ts @@ -76,10 +76,20 @@ export const NOVNC_HTML = ` }; var msg=document.getElementById('msg'); - // noVNC per ESM vom CDN laden. + + // Verstecktes Eingabefeld → Software-Tastatur des Handys tippt in die VM. + var kbd=document.createElement('input'); + kbd.setAttribute('autocomplete','off'); kbd.setAttribute('autocorrect','off'); + kbd.setAttribute('autocapitalize','off'); kbd.spellcheck=false; + kbd.style.cssText='position:absolute;left:-1000px;top:0;width:1px;height:1px;opacity:0;'; + document.body.appendChild(kbd); + var SPECIAL={Enter:0xff0d,Backspace:0xff08,Tab:0xff09,Escape:0xff1b,Delete:0xffff, + ArrowLeft:0xff51,ArrowUp:0xff52,ArrowRight:0xff53,ArrowDown:0xff54,Home:0xff50,End:0xff57}; + import('https://cdn.jsdelivr.net/npm/@novnc/novnc@1.4.0/core/rfb.js').then(function(mod){ var RFB = mod.default; var rfb = new RFB(document.getElementById('screen'), 'ws://aria-vnc/', {}); + var fit=true; rfb.scaleViewport = true; rfb.clipViewport = false; rfb.addEventListener('connect', function(){ msg.style.display='none'; post({event:'vnc_state', state:'connected'}); }); @@ -88,6 +98,24 @@ export const NOVNC_HTML = ` post({event:'vnc_state', state:'disconnected'}); }); window.__rfb = rfb; + + // Tasten aus dem versteckten Feld an die VM schicken. + function tap(keysym, code){ try{ rfb.sendKey(keysym, code||null, true); rfb.sendKey(keysym, code||null, false); }catch(_){} } + kbd.addEventListener('keydown', function(e){ + if(SPECIAL[e.key]!==undefined){ tap(SPECIAL[e.key], e.code); e.preventDefault(); } + }); + kbd.addEventListener('input', function(){ + var v=kbd.value; for(var i=0;i = { - chat: { x: 0, y: 0, w: TILE_W, h: TILE_H }, - editor: { x: TILE_W + GAP, y: 0, w: TILE_W, h: TILE_H }, - vnc: { x: 0, y: TILE_H + GAP, w: TILE_W, h: TILE_H }, - preview: { x: TILE_W + GAP, y: TILE_H + GAP, w: TILE_W, h: TILE_H }, -}; +export interface TileDef { id: TileId; title: string; icon: string } export const TILE_META: Record = { chat: { id: 'chat', title: 'Chat', icon: '💬' }, - editor: { id: 'editor', title: 'Editor', icon: '📝' }, + editor: { id: 'editor', title: 'Code', icon: '📝' }, vnc: { id: 'vnc', title: 'Desktop', icon: '🖥️' }, preview: { id: 'preview', title: 'Vorschau', icon: '🖼️' }, }; - -// Reihenfolge fuer stabiles Rendering. -export const TILE_ORDER: TileId[] = ['chat', 'editor', 'vnc', 'preview']; - -export interface VisibilitySignals { - kind: 'code' | 'chat'; - hasCode: boolean; // schon ein code_file empfangen - hasDesktop: boolean; // Desktop verfuegbar gemeldet -} - -/** Welche Kacheln sind fuer den aktuellen Kontext sichtbar? - * Chat ist immer da; Code-Projekt (explizit ODER durch ein Live-Signal) - * blendet Editor/Desktop/Vorschau ein. */ -export function visibleTilesFor(sig: VisibilitySignals): TileId[] { - const isCode = sig.kind === 'code' || sig.hasCode || sig.hasDesktop; - if (!isCode) return ['chat']; - return ['chat', 'editor', 'vnc', 'preview']; -} - -/** Bounding-Box mehrerer Kacheln. */ -export function boundsOf(ids: TileId[]): TileRect { - if (ids.length === 0) return { x: 0, y: 0, w: TILE_W, h: TILE_H }; - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - for (const id of ids) { - const r = TILE_RECTS[id]; - minX = Math.min(minX, r.x); - minY = Math.min(minY, r.y); - maxX = Math.max(maxX, r.x + r.w); - maxY = Math.max(maxY, r.y + r.h); - } - return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; -} - -/** Rect in Welt-View-lokale Koordinaten (relativ zur Bounds-Ecke) umrechnen. */ -export function toLocal(rect: TileRect, bounds: TileRect): TileRect { - return { x: rect.x - bounds.x, y: rect.y - bounds.y, w: rect.w, h: rect.h }; -} - -export interface Camera { scale: number; tx: number; ty: number; } - -/** Kamera, die ein (lokales) Rect bildschirmfuellend zeigt (Fokus-Modus). */ -export function focusCamera(localRect: TileRect, worldW: number, worldH: number, vw: number, vh: number): Camera { - const s = Math.min(vw / localRect.w, vh / localRect.h); - const pcx = localRect.x + localRect.w / 2; - const pcy = localRect.y + localRect.h / 2; - const tx = vw / 2 - worldW / 2 - s * (pcx - worldW / 2); - const ty = vh / 2 - worldH / 2 - s * (pcy - worldH / 2); - return { scale: s, tx, ty }; -} - -/** Kamera, die die gesamte Welt zentriert einpasst (Uebersicht). */ -export function overviewCamera(worldW: number, worldH: number, vw: number, vh: number, pad = 0.86): Camera { - const s = Math.min(vw / worldW, vh / worldH) * pad; - const tx = vw / 2 - worldW / 2; - const ty = vh / 2 - worldH / 2; - return { scale: s, tx, ty }; -} diff --git a/android/src/workspace/tiles/PreviewTile.tsx b/android/src/workspace/tiles/PreviewTile.tsx deleted file mode 100644 index 58cfa65..0000000 --- a/android/src/workspace/tiles/PreviewTile.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/** - * PreviewTile — zeigt den letzten Screenshot/Vorschau-Frame eines Code-Projekts - * (z. B. aria-vm screenshot). Fuellt sich, sobald ARIA ein Bild in den - * Vorschau-Kanal legt; bis dahin ein ruhiger Platzhalter. - * - * (Screenshot-Anbindung folgt mit dem QEMU-Track; hier zunaechst die Kachel.) - */ - -import React from 'react'; -import { Image, StyleSheet, Text, View } from 'react-native'; - -interface Props { - imageUri?: string; -} - -const PreviewTile: React.FC = ({ imageUri }) => { - return ( - - {imageUri ? ( - - ) : ( - <> - 🖼️ - Noch keine Vorschau - Screenshots der VM erscheinen hier. - - )} - - ); -}; - -const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: '#0D0D1A', alignItems: 'center', justifyContent: 'center' }, - image: { width: '100%', height: '100%' }, - icon: { fontSize: 64, marginBottom: 16 }, - text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' }, - sub: { color: '#9090B0', fontSize: 14, marginTop: 8 }, -}); - -export default PreviewTile; diff --git a/android/src/workspace/tiles/VncTile.tsx b/android/src/workspace/tiles/VncTile.tsx index 19d8124..08caefe 100644 --- a/android/src/workspace/tiles/VncTile.tsx +++ b/android/src/workspace/tiles/VncTile.tsx @@ -1,15 +1,14 @@ /** * VncTile — Live-Desktop der QEMU-VM (noVNC in einer WebView, RFB durch RVS). * - * Nur im Fokus aktiv: dann wird die WebView gemountet, bei 'ready' der - * RVS-VNC-Tunnel geoeffnet (desktop.openVnc). Server-Bytes (vnc_data) werden in - * die WebView injiziert, RFB-Bytes der WebView (vnc_send) gehen als vnc_input - * zurueck. Verlaesst man die Kachel, wird der Tunnel geschlossen (die VM laeuft - * auf dem Host weiter). + * Nur aktiv, wenn das Desktop-Panel offen ist (focused): dann WebView mounten, + * bei 'ready' den RVS-VNC-Tunnel oeffnen. Eine kleine Steuerungs-Leiste macht + * die VM auf dem Handy bedienbar: Tastatur einblenden (tippt in die VM), + * Strg-Alt-Entf, und Fit ↔ 1:1 umschalten. */ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { StyleSheet, Text, View } from 'react-native'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { WebView, WebViewMessageEvent } from 'react-native-webview'; import desktop from '../../services/desktop'; import { NOVNC_HTML } from '../assets/novncHtml'; @@ -24,23 +23,24 @@ const VncTile: React.FC = ({ projectId, focused }) => { const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'disconnected'>('idle'); const unsubDataRef = useRef void)>(null); - // Aufraeumen: Tunnel zu + Daten-Abo weg. const teardown = useCallback(() => { if (unsubDataRef.current) { unsubDataRef.current(); unsubDataRef.current = null; } desktop.closeVnc(); }, []); - // Fokus verloren / Unmount → Tunnel schliessen. useEffect(() => { if (!focused) { teardown(); setStatus('idle'); } return () => teardown(); }, [focused, teardown]); + const ctl = useCallback((fn: string) => { + webRef.current?.injectJavaScript(`window.ariaVncCtl && window.ariaVncCtl.${fn}(); true;`); + }, []); + const onMessage = useCallback((e: WebViewMessageEvent) => { let m: any; try { m = JSON.parse(e.nativeEvent.data); } catch { return; } if (m.event === 'ready') { - // WebView + RFB bereit → Tunnel oeffnen und Server-Bytes einspeisen. setStatus('connecting'); unsubDataRef.current = desktop.onVncData((b64) => { const js = `window.ariaVnc && window.ariaVnc.onData(${JSON.stringify(b64)}); true;`; @@ -62,11 +62,12 @@ const VncTile: React.FC = ({ projectId, focused }) => { 🖥️ Desktop - Antippen zum Verbinden + Panel öffnen zum Verbinden ); } + const connected = status === 'connected'; return ( = ({ projectId, focused }) => { domStorageEnabled mixedContentMode="always" androidLayerType="hardware" + keyboardDisplayRequiresUserAction={false} /> - {status !== 'connected' && ( + + {/* Steuerungs-Leiste — nur wenn verbunden */} + {connected && ( + + ctl('focusKeyboard')} activeOpacity={0.7}> + + + ctl('cad')} activeOpacity={0.7}> + Strg+Alt+Entf + + ctl('toggleFit')} activeOpacity={0.7}> + + + + )} + + {!connected && ( {status === 'connecting' ? 'Verbinde …' : status === 'disconnected' ? 'Getrennt' : ''} @@ -98,7 +116,27 @@ const styles = StyleSheet.create({ icon: { fontSize: 64, marginBottom: 16 }, text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' }, sub: { color: '#9090B0', fontSize: 14, marginTop: 8 }, - overlay: { position: 'absolute', top: 10, left: 0, right: 0, alignItems: 'center' }, + ctlBar: { + position: 'absolute', + top: 8, + right: 8, + flexDirection: 'row', + gap: 6, + }, + ctlBtn: { + backgroundColor: 'rgba(18,18,42,0.9)', + borderColor: '#2A2A3E', + borderWidth: 1, + borderRadius: 10, + paddingHorizontal: 10, + paddingVertical: 7, + minWidth: 38, + alignItems: 'center', + justifyContent: 'center', + }, + ctlText: { color: '#E0E0F0', fontSize: 16, fontWeight: '700' }, + ctlTextSmall: { color: '#E0E0F0', fontSize: 11, fontWeight: '700' }, + overlay: { position: 'absolute', top: 12, left: 0, right: 0, alignItems: 'center' }, overlayText: { color: '#9090B0', fontSize: 12, backgroundColor: 'rgba(0,0,0,0.6)', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 10, overflow: 'hidden' }, });