feat(app): Workbench-Dock statt Zoom-Landkarte + bedienbare VNC-Konsole (0.2.2.0)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 21:44:32 +02:00
co-authored by Claude Opus 4.8
parent 7d46d8e531
commit 0110526622
14 changed files with 317 additions and 551 deletions
+19
View File
@@ -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
+3 -2
View File
@@ -500,7 +500,7 @@ Erreichbar unter `http://<VM-IP>: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/<id>/` 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/<id>/` 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
-2
View File
@@ -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: () => <CockpitOverviewButton />,
headerRight: () => <ViewModeToggle />,
}}
/>
@@ -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<ViewModeValue>(viewMode.get());
const [focused, setFocused] = useState<boolean>(cockpitNav.isFocused());
useEffect(() => viewMode.subscribe(setMode), []);
useEffect(() => cockpitNav.subscribe(setFocused), []);
if (mode !== 'cockpit' || !focused) return null;
return (
<TouchableOpacity
onPress={() => cockpitNav.requestOverview()}
style={styles.pill}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.75}
>
<Text style={styles.text}> Übersicht</Text>
</TouchableOpacity>
);
};
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;
-43
View File
@@ -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;
-61
View File
@@ -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<Props> = ({ id, rect, subtitle, onFocus }) => {
const meta = TILE_META[id];
const tap = Gesture.Tap()
.maxDuration(300)
.onEnd((_e, success) => {
if (success) runOnJS(onFocus)(id);
});
return (
<GestureDetector gesture={tap}>
<View style={[styles.tile, { left: rect.x, top: rect.y, width: rect.w, height: rect.h }]}>
<Text style={styles.icon}>{meta.icon}</Text>
<Text style={styles.title}>{meta.title}</Text>
{!!subtitle && <Text style={styles.subtitle} numberOfLines={2}>{subtitle}</Text>}
<Text style={styles.hint}>Tippen zum Öffnen</Text>
</View>
</GestureDetector>
);
};
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;
-232
View File
@@ -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<Record<TileId, string>>;
}
const ANIM = { duration: 260 };
const MIN_SCALE = 0.15;
const MAX_SCALE = 4;
const WorkspaceCanvas: React.FC<Props> = ({ projectId, visibleTiles, subtitles }) => {
const { width: vw, height: vh } = useWindowDimensions();
const [focusedTileId, setFocusedTileId] = useState<TileId | null>('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 <ChatTile />;
case 'editor': return <CodeEditorTile projectId={projectId} />;
case 'vnc': return <VncTile projectId={projectId} focused={focusedTileId === 'vnc'} />;
case 'preview': return <PreviewTile />;
default: return null;
}
};
return (
<View style={styles.root}>
{/* Ebene 1 — skalierte Landkarte mit Thumbnails */}
<View style={StyleSheet.absoluteFill} pointerEvents={focusedTileId ? 'none' : 'auto'}>
<GestureDetector gesture={canvasGesture}>
<Animated.View style={[styles.world, { width: worldW, height: worldH }, worldStyle]}>
{visibleTiles.map((id) => (
<Tile
key={id}
id={id}
rect={toLocal(TILE_RECTS[id], bounds)}
subtitle={subtitles?.[id]}
onFocus={setFocusedTileId}
/>
))}
</Animated.View>
</GestureDetector>
</View>
{/* Ebene 2 — Identity-Content, immer gemountet, nur fokussierte sichtbar */}
<View style={StyleSheet.absoluteFill} pointerEvents={focusedTileId ? 'box-none' : 'none'}>
{visibleTiles.map((id) => (
<View
key={id}
style={[StyleSheet.absoluteFill, { display: focusedTileId === id ? 'flex' : 'none' }]}
>
{renderContent(id)}
</View>
))}
</View>
{/* Steuerung — der „⤢ Übersicht"-Button sitzt im Header (cockpitNav),
nicht hier im Chat-Inhalt. */}
{!focusedTileId && (
<View style={styles.hintBar} pointerEvents="none">
<Text style={styles.hintText}>Kachel antippen zum Öffnen · 2 Finger: zoomen & schieben</Text>
</View>
)}
</View>
);
};
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;
+90
View File
@@ -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<Record<TileId, string>>;
}
const WorkspaceDeck: React.FC<Props> = ({ projectId, panels, badges }) => {
const [active, setActive] = useState<TileId>('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 <ChatTile />;
case 'editor': return <CodeEditorTile projectId={projectId} />;
case 'vnc': return <VncTile projectId={projectId} focused={active === 'vnc'} />;
default: return null;
}
};
return (
<View style={styles.root}>
<View style={styles.stack}>
{panels.map((id) => (
<View
key={id}
style={[StyleSheet.absoluteFill, { display: active === id ? 'flex' : 'none' }]}
>
{render(id)}
</View>
))}
</View>
{!kbVisible && (
<WorkspaceDock panels={panels} active={active} badges={badges} onSelect={setActive} />
)}
</View>
);
};
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: '#0D0D1A' },
stack: { flex: 1, position: 'relative' },
});
export default WorkspaceDeck;
+108
View File
@@ -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<Record<TileId, string>>; // TileId → Punkt-Farbe (undefined = kein Punkt)
onSelect: (id: TileId) => void;
}
const WorkspaceDock: React.FC<Props> = ({ 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 (
<View style={[styles.dock, { paddingBottom: Math.max(insets.bottom, 6) }]}>
<View style={styles.row} onLayout={onLayout}>
{rowW > 0 && <Animated.View style={[styles.indicator, indicatorStyle]} pointerEvents="none" />}
{panels.map((id) => {
const meta = TILE_META[id];
const isActive = id === active;
const badge = badges?.[id];
return (
<TouchableOpacity
key={id}
style={styles.item}
onPress={() => onSelect(id)}
activeOpacity={0.7}
>
<View>
<Text style={[styles.icon, isActive && styles.iconActive]}>{meta.icon}</Text>
{!!badge && <View style={[styles.badge, { backgroundColor: badge }]} />}
</View>
<Text style={[styles.label, isActive && styles.labelActive]} numberOfLines={1}>
{meta.title}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
);
};
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;
+15 -26
View File
@@ -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<ViewModeValue>(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<Record<TileId, string>>;
}, [pid, focus.projectNameById, isCode, hasCode, hasDesktop]);
const badges = useMemo(() => ({
editor: hasCode ? '#0096FF' : undefined,
vnc: hasDesktop ? '#34C759' : undefined,
} as Partial<Record<TileId, string>>), [hasCode, hasDesktop]);
// Kompakt-Ansicht: klassischer Vollbild-Chat, exakt wie vor dem Umbau.
if (mode === 'compact') {
return <ChatScreen />;
}
// Cockpit: zoombarer Kachel-Desktop.
return <WorkspaceCanvas projectId={pid} visibleTiles={visibleTiles} subtitles={subtitles} />;
// Cockpit: Workbench mit Dock.
return <WorkspaceDeck projectId={pid} panels={COCKPIT_PANELS} badges={badges} />;
};
export default WorkspaceScreen;
+29 -1
View File
@@ -76,10 +76,20 @@ export const NOVNC_HTML = `<!doctype html><html><head><meta charset="utf-8">
};
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 = `<!doctype html><html><head><meta charset="utf-8">
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<v.length;i++){ tap(v.charCodeAt(i)); } kbd.value='';
});
// Steuerungs-API fuer die App (per injectJavaScript).
window.ariaVncCtl = {
focusKeyboard: function(){ try{ kbd.focus(); }catch(_){} },
blurKeyboard: function(){ try{ kbd.blur(); }catch(_){} },
cad: function(){ try{ rfb.sendCtrlAltDel(); }catch(_){} },
toggleFit: function(){ fit=!fit; rfb.scaleViewport=fit; rfb.clipViewport=!fit; post({event:'vnc_fit', fit:fit}); }
};
post({event:'ready'});
}).catch(function(err){
msg.textContent='noVNC konnte nicht geladen werden (Internet?)';
+3 -84
View File
@@ -1,95 +1,14 @@
/**
* layout — Kachel-Geometrie + Kamera-Mathematik fuer den Workspace-Canvas.
*
* Welt-Koordinaten = Pixel bei Scale 1. Kacheln liegen auf einem festen 2x2-
* Raster. Die "Welt-View" (Animated.View) ist exakt die Bounding-Box der gerade
* sichtbaren Kacheln; Kinder werden relativ zu deren Ursprung positioniert.
*
* RN 0.73 kennt noch kein transformOrigin — Scale dreht um die View-MITTE.
* Alle Kamera-Formeln rechnen deshalb mit Center-Origin:
* screen = center + scale*(p - center) + translate
* wobei center = (worldW/2, worldH/2) (die Welt-View sitzt bei screen 0,0).
* layout — Panel-Definitionen der Workbench (Metadaten fuer das Dock).
*/
export type TileId = 'chat' | 'editor' | 'vnc' | 'preview';
export interface TileRect { x: number; y: number; w: number; h: number; }
export interface TileDef { id: TileId; title: string; icon: string; }
// Feste Kachelgroesse im Welt-Raster (Pixel bei Scale 1).
const TILE_W = 1100;
const TILE_H = 1500;
const GAP = 140;
/** Absolute Welt-Rects pro Kachel (2x2-Raster). */
export const TILE_RECTS: Record<TileId, TileRect> = {
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<TileId, TileDef> = {
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 };
}
@@ -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<Props> = ({ imageUri }) => {
return (
<View style={styles.container}>
{imageUri ? (
<Image source={{ uri: imageUri }} style={styles.image} resizeMode="contain" />
) : (
<>
<Text style={styles.icon}>🖼</Text>
<Text style={styles.text}>Noch keine Vorschau</Text>
<Text style={styles.sub}>Screenshots der VM erscheinen hier.</Text>
</>
)}
</View>
);
};
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;
+50 -12
View File
@@ -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<Props> = ({ projectId, focused }) => {
const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'disconnected'>('idle');
const unsubDataRef = useRef<null | (() => 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<Props> = ({ projectId, focused }) => {
<View style={styles.placeholder}>
<Text style={styles.icon}>🖥</Text>
<Text style={styles.text}>Desktop</Text>
<Text style={styles.sub}>Antippen zum Verbinden</Text>
<Text style={styles.sub}>Panel öffnen zum Verbinden</Text>
</View>
);
}
const connected = status === 'connected';
return (
<View style={styles.container}>
<WebView
@@ -79,8 +80,25 @@ const VncTile: React.FC<Props> = ({ projectId, focused }) => {
domStorageEnabled
mixedContentMode="always"
androidLayerType="hardware"
keyboardDisplayRequiresUserAction={false}
/>
{status !== 'connected' && (
{/* Steuerungs-Leiste — nur wenn verbunden */}
{connected && (
<View style={styles.ctlBar}>
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('focusKeyboard')} activeOpacity={0.7}>
<Text style={styles.ctlText}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('cad')} activeOpacity={0.7}>
<Text style={styles.ctlTextSmall}>Strg+Alt+Entf</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('toggleFit')} activeOpacity={0.7}>
<Text style={styles.ctlText}></Text>
</TouchableOpacity>
</View>
)}
{!connected && (
<View style={styles.overlay} pointerEvents="none">
<Text style={styles.overlayText}>
{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' },
});