Files
ARIA-AGENT/android/src/workspace/WorkspaceCanvas.tsx
T
duffyduckandClaude Opus 4.8 b839b059d0 fix(app): Cockpit — alle Kacheln sichtbar + Uebersicht-Button in den Header
Zwei Nachbesserungen am Cockpit-Modus (noch Teil von 0.2.1.9):
- Im Cockpit werden IMMER alle vier Kacheln gezeigt (Chat/Editor/Desktop/
  Vorschau) statt erst bei Code-Projekten — Editor/Desktop/Vorschau als
  Platzhalter mit Status-Untertitel. Vorher wirkten sie "verschwunden".
- Der "⤢ Uebersicht"-Button hing im Chat genau ueber dem Abbrechen-Button der
  "ARIA denkt"-Leiste. Jetzt sitzt er im Navigations-Header links (nur sichtbar
  im Cockpit + Fokus), via neuem cockpitNav-Signal-Singleton. In-Content-Button
  entfernt.

tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:46:49 +02:00

233 lines
8.1 KiB
TypeScript

/**
* 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;