+
+
+
+
+
+
+
Expected communication
+
+
+
+
+
+
+
+
+
+ mdi-radio-tower
+ ATC
+
+
{{ displayControllerSay }}
+
+
+
+
+ mdi-account-pilot
+ Pilot (You)
+
+
{{ displayExpectedPhrase }}
+
+
+
-
-
Session: {{ sessionLabel }}
-
-
- Auto: {{ traceAutoSelection.id }}
-
-
- Fallback candidates
-
-
- Fallback: {{ traceFallback.reason || 'triggered' }}
-
-
+
+
+
+ Sprechen
+ Tippen
+
-
-
Current node
-
{{ debugState?.id || '—' }}
-
- {{ debugState ? `${debugState.role} • ${debugState.phase}` : 'N/A' }}
- ({{ debugState.frequencyName }})
-
-
- Auto (LLM): {{ debugState.sayPlain }}
-
-
- Radio: {{ debugState.sayNormalized }}
-
-
-
-
-
Upcoming decisions
-
-
+
+
+
-
+
ATC
+
Intercom
+
+
+
+ Microphone permission required for push-to-talk.
+
+ Allow
+
+
+
+
+
-
{{ state.id }}
-
- {{ state.role || '—' }} • {{ state.phase || '—' }}
- ({{ state.frequencyName }})
+
+
+ {{ isRecording ? 'TRANSMITTING' : 'Hold to transmit' }}
+
+
+ {{ radioMode === 'atc' ? 'ATC' : 'INTERCOM' }}
+
+
+ {{ radioMode === 'atc' ? `Transmitting on ${frequencies.active}` : 'Crew Intercom • Checklist' }}
-
- Jump
-
-
- ATC: {{ state.sayPlain }}
-
-
- Radio: {{ state.sayNormalized }}
-
-
-
-
No further decisions available.
+
+
-
-
Decision timeline
-
-
+
+
+
+
+
+
Text Input
+ {{ radioMode === 'atc' ? 'ATC' : 'Intercom' }}
+
+
+
+
+
Suggested phrase
+
+
+ {{ backendExpectedPhrase }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Last transmission
+
+
+
+
+ Faulty
+
+
+
+ mdi-alert-circle-outline
+
+ {{ lastTransmissionFaulty ? 'Edit issue' : 'Mark as faulty' }}
+
+
+
+ mdi-restore
+
+ Reset
+
+
+
+ mdi-close
+
+ Delete
+
+
+
+ {{ lastTransmission }}
+
+
Issue description
+
+ {{ lastTransmissionFaultNote }}
+
+
+ Marked as faulty.
+
+
+
+
+
+
+
+
+
+
+
+
Radio Setup
+
+ {{ frequencies.active || '---' }}
+
+
+
+
+
+
+ Presets — tippen für Active, halten & ziehen für Auswahl
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ presetLabel(preset) }} · {{ preset.frequency }}
+
+
+
+
+
+
+
+
+
+
+ Swap frequencies
+
+
+
+
+
+
+
+
+
+
+
Frequency overview
+
+
+
+
+ {{ label }}
+
+
+
+
+
+
+
+
+ Frequencies for {{ flightContext.dep || 'departure' }}
+
+
+
+
+ Loading frequencies from the network…
+
+
+ No frequencies available. Please try again later.
+
+
+
+
+
+
+ {{ freq.type }}
+ {{ freq.label }}
+
+
+ {{ freq.callsign }}
+
+
+ {{ freq.source === 'vatsim' ? 'VATSIM' : 'OpenAIP' }}
+
+
+
+
{{ freq.frequency }}
+
+
+
+ mdi-crosshairs-gps
+
+ Active
+
+
+
+ mdi-timer-sand
+
+ Standby
+
+
+
+
+
+
+ {{ freq.source.toUpperCase() }}
+
+
+ ATIS {{ freq.atisCode }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
{{ step.label }}
-
{{ step.stage }}
+
Active flight
+
{{ flightContext.callsign || 'N/A' }}
+
{{ flightContext.dep }} → {{ flightContext.dest }}
-
- {{ step.candidates.length }} candidates
+
+
+ {{ flags.in_air ? 'IN-AIR' : 'GROUND' }}
+
+
+
+ EMERG
+
+
+ {{ flags.current_unit }}
+
+
+
+
+
+
+
+
Stand
+
{{ vars.stand }}
+
+
+
Runway
+
{{ vars.runway }}
+
+
+
Squawk
+
{{ vars.squawk }}
+
+
+
+
+
+
+
{{ flags.radio_checks_done || 0 }}
+
Radio Checks
+
+
+
{{ flags.off_schema_count || 0 }}
+
Off-Schema
+
+
+
{{ log.length }}
+
Transmissions
+
+
+
+
+ Select new flight
+
+
+
+
+
+
+
+
+
+ Settings
+
+
+
+
+
+
+
+
+
+
+ {{ speechSpeedLabel }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Simulation Trace
+
+ {{ completedPilotSteps }} / {{ simulationStepCount }}
+
+
+
+ {{ simulationRunning ? 'Running' : 'Ready' }}
- {{ step.note }}
-
-
-
- {{ candidate.id }}
- {{ candidate.flow || 'current' }}
-
-
{{ candidate.summary }}
-
-
-
-
Eliminated
-
-
- {{ elim.candidate.id }}
- {{ elim.kind }}
-
-
{{ elim.reason }}
-
{{ describeElimination(elim) }}
-
-
-
-
-
No decision timeline available yet.
-
-
-
-
-
-
-
-
-
Communication Log
- {{ log.length }}
-
-
- Clear
-
-
-
-
-
-
-
-
- {{ entry.speaker.toUpperCase() }}
- RADIO CHECK
- OFF-SCHEMA
-
- {{ formatTime(entry.timestamp) }}
-
-
{{ entry.message }}
-
-
- {{ entry.flow }}
-
- {{ entry.frequency || 'N/A' }}
- {{ entry.state }}
-
-
+ {{ simulationError }}
+
-
- No communications recorded yet.
-
+
+ Simulation initializing...
+
+
+
+
+
+ {{ entry.label }}
+ {{ entry.id }}
+
+
+
{{ entry.payload?.text }}
+
{{ entry.payload?.normalized }}
+
+
{{ formatTracePayload(entry.payload) }}
+
+
+
+
+
+
+
+
+
Debug: Flow Insights
+ LLM
+
+
+
+
Session: {{ sessionLabel }}
+
+
+ Auto: {{ traceAutoSelection.id }}
+
+
+ Fallback candidates
+
+
+ Fallback: {{ traceFallback.reason || 'triggered' }}
+
+
+
+
+
+
Current node
+
{{ debugState?.id || '—' }}
+
+ {{ debugState ? `${debugState.role} • ${debugState.phase}` : 'N/A' }}
+ ({{ debugState.frequencyName }})
+
+
+ Auto (LLM): {{ debugState.sayPlain }}
+
+
+ Radio: {{ debugState.sayNormalized }}
+
+
+
+
+
Upcoming decisions
+
+
+
+
+
{{ state.id }}
+
+ {{ state.role || '—' }} • {{ state.phase || '—' }}
+ ({{ state.frequencyName }})
+
+
+
+ Jump
+
+
+
+ ATC: {{ state.sayPlain }}
+
+
+ Radio: {{ state.sayNormalized }}
+
+
+
+
No further decisions available.
+
+
+
+
Decision timeline
+
+
+
+
+
{{ step.label }}
+
{{ step.stage }}
+
+
+ {{ step.candidates.length }} candidates
+
+
+
{{ step.note }}
+
+
+
+ {{ candidate.id }}
+ {{ candidate.flow || 'current' }}
+
+
{{ candidate.summary }}
+
+
+
+
Eliminated
+
+
+ {{ elim.candidate.id }}
+ {{ elim.kind }}
+
+
{{ elim.reason }}
+
{{ describeElimination(elim) }}
+
+
+
+
+
No decision timeline available yet.
+
+
+
-
-
+
+
-
-
+
+
+
+
+
+
+
@@ -1464,6 +1568,29 @@ const radioEffectsEnabled = ref(true)
const readbackEnabled = ref(false)
const debugMode = ref(true)
+// Layout / view state
+const activeTab = ref<'funk' | 'freq' | 'log' | 'flug' | 'more' | 'debug'>('funk')
+const inputMode = ref<'voice' | 'text'>('voice')
+const learningMode = ref(true)
+
+type PmTab = {
+ id: 'funk' | 'freq' | 'log' | 'flug' | 'more' | 'debug'
+ label: string
+ icon: string
+ mobileOnly?: boolean
+ debugOnly?: boolean
+}
+const TABS: PmTab[] = [
+ { id: 'funk', label: 'Funk', icon: 'mdi-radio-handheld' },
+ { id: 'freq', label: 'Freq', icon: 'mdi-sine-wave' },
+ { id: 'log', label: 'Log', icon: 'mdi-format-list-bulleted', mobileOnly: true },
+ { id: 'flug', label: 'Flug', icon: 'mdi-airplane' },
+ { id: 'more', label: 'Mehr', icon: 'mdi-cog' },
+ { id: 'debug', label: 'Debug', icon: 'mdi-bug', debugOnly: true },
+]
+const visibleTabs = computed(() => TABS.filter((tab) => !tab.debugOnly || debugMode.value))
+const desktopTabs = computed(() => visibleTabs.value.filter((tab) => !tab.mobileOnly))
+
const simulationRunning = ref(false)
const simulationTrace = ref
([])
const simulationError = ref('')
@@ -2604,6 +2731,57 @@ const swapFrequencies = () => {
}, 500)
}
+// --- Frequency presets (for the hold-to-select gesture) ---------------------
+const FREQ_ROLE_ORDER = ['ATIS', 'DEL', 'CLD', 'GND', 'TWR', 'DEP', 'APP', 'CTR', 'ACC', 'FSS']
+const FREQ_ROLE_LABEL: Record = {
+ ATIS: 'ATIS', DEL: 'Delivery', CLD: 'Delivery', GND: 'Ground', TWR: 'Tower',
+ DEP: 'Departure', APP: 'Approach', CTR: 'Center', ACC: 'Center', FSS: 'Radio',
+}
+
+const frequencyPresets = computed(() => {
+ const byType = new Map()
+ const prioritized = [...airportFrequencies.value].sort((a, b) =>
+ a.source === b.source ? 0 : (a.source === 'vatsim' ? -1 : 1)
+ )
+ for (const entry of prioritized) {
+ if (!entry.frequency || entry.frequency === FREQUENCY_PLACEHOLDER) continue
+ if (!byType.has(entry.type)) byType.set(entry.type, entry)
+ }
+ const result: AirportFrequencyEntry[] = []
+ for (const type of FREQ_ROLE_ORDER) {
+ const entry = byType.get(type)
+ if (entry) result.push(entry)
+ }
+ for (const [type, entry] of byType) {
+ if (!FREQ_ROLE_ORDER.includes(type)) result.push(entry)
+ }
+ return result
+})
+
+const presetKey = (entry: AirportFrequencyEntry) => `${entry.type}-${entry.frequency}`
+const presetLabel = (entry: AirportFrequencyEntry) => FREQ_ROLE_LABEL[entry.type] || entry.type
+
+const presetOptions = computed(() =>
+ frequencyPresets.value.map((entry) => ({
+ value: presetKey(entry),
+ label: presetLabel(entry),
+ sublabel: entry.frequency,
+ color: entry.type === 'ATIS' ? '#f59e0b' : '#22d3ee',
+ }))
+)
+
+const findPreset = (value: string | number) =>
+ frequencyPresets.value.find((entry) => presetKey(entry) === value)
+
+const onPresetSelectActive = (opt: { value: string | number }) => {
+ const entry = findPreset(opt.value)
+ if (entry) setActiveFrequencyFromList(entry)
+}
+const onPresetSelectStandby = (opt: { value: string | number }) => {
+ const entry = findPreset(opt.value)
+ if (entry) setStandbyFrequencyFromList(entry)
+}
+
const formatTime = (date: Date): string => {
return date.toLocaleTimeString('en-US', {
hour12: false,
@@ -2896,4 +3074,206 @@ watch(() => activeFrequency.value, (newFreq) => {
.animate-pulse {
animation: pulse 1s infinite;
}
+
+/* ---------------------------------------------------------------------------
+ App shell layout (monitor screen)
+ --------------------------------------------------------------------------- */
+.pm-shell {
+ display: flex;
+ flex-direction: column;
+ height: 100dvh;
+ overflow: hidden;
+}
+
+.pm-topbar {
+ flex: 0 0 auto;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(5, 9, 16, 0.85);
+ backdrop-filter: blur(10px);
+ padding: calc(env(safe-area-inset-top) + 8px) 12px 8px;
+}
+.pm-topbar-inner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ max-width: 1400px;
+ margin: 0 auto;
+ width: 100%;
+}
+
+.pm-body {
+ flex: 1 1 auto;
+ display: flex;
+ min-height: 0;
+ width: 100%;
+ max-width: 1400px;
+ margin: 0 auto;
+}
+
+.pm-sidenav {
+ display: none;
+}
+
+.pm-main {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+}
+.pm-main-inner {
+ max-width: 720px;
+ margin: 0 auto;
+ padding: 16px 14px 24px;
+}
+.pm-block {
+ margin-bottom: 16px;
+}
+.pm-block:last-child {
+ margin-bottom: 0;
+}
+
+.pm-lograil {
+ display: none;
+}
+
+.pm-bottomnav {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: stretch;
+ justify-content: space-around;
+ gap: 2px;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(5, 9, 16, 0.95);
+ backdrop-filter: blur(10px);
+ padding: 6px 6px calc(env(safe-area-inset-bottom) + 6px);
+}
+
+.pm-navbtn {
+ flex: 1 1 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 2px;
+ padding: 6px 4px;
+ border-radius: 12px;
+ color: rgba(255, 255, 255, 0.55);
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ transition: color 120ms ease, background 120ms ease;
+ -webkit-tap-highlight-color: transparent;
+}
+.pm-navbtn:hover {
+ color: rgba(255, 255, 255, 0.85);
+}
+.pm-navbtn.is-active {
+ color: #22d3ee;
+ background: rgba(34, 211, 238, 0.12);
+}
+
+/* Frequency chip in the top bar (also the hold-select trigger) */
+.freq-chip {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 1px;
+ padding: 6px 12px;
+ border-radius: 14px;
+ border: 1px solid rgba(34, 211, 238, 0.35);
+ background: rgba(34, 211, 238, 0.1);
+ line-height: 1.1;
+ transition: border-color 120ms ease, background 120ms ease, transform 80ms ease;
+}
+.freq-chip.is-open {
+ border-color: rgba(34, 211, 238, 0.8);
+ background: rgba(34, 211, 238, 0.18);
+ transform: scale(0.98);
+}
+.freq-chip-row {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+}
+.freq-chip-tag {
+ font-size: 9px;
+ letter-spacing: 0.2em;
+ color: rgba(255, 255, 255, 0.5);
+}
+.freq-chip-value {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 18px;
+ font-weight: 700;
+ color: #fff;
+}
+.freq-chip-standby {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 10px;
+ color: rgba(245, 158, 11, 0.9);
+}
+
+/* Preset hold-select trigger buttons (freq tab) */
+.preset-action-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 14px;
+ border-radius: 999px;
+ border: 1px solid rgba(34, 211, 238, 0.4);
+ background: rgba(34, 211, 238, 0.1);
+ color: #67e8f9;
+ font-size: 13px;
+ font-weight: 600;
+ transition: border-color 120ms ease, background 120ms ease, transform 80ms ease;
+}
+.preset-action-chip.standby {
+ border-color: rgba(245, 158, 11, 0.4);
+ background: rgba(245, 158, 11, 0.1);
+ color: #fbbf24;
+}
+.preset-action-chip.is-open {
+ transform: scale(0.97);
+}
+
+/* Tablet / desktop: use the extra space ----------------------------------- */
+@media (min-width: 1024px) {
+ .pm-sidenav {
+ display: flex;
+ flex: 0 0 auto;
+ flex-direction: column;
+ gap: 6px;
+ width: 132px;
+ padding: 16px 10px;
+ border-right: 1px solid rgba(255, 255, 255, 0.08);
+ overflow-y: auto;
+ }
+ .pm-sidenav .pm-navbtn {
+ flex: 0 0 auto;
+ flex-direction: row;
+ justify-content: flex-start;
+ gap: 10px;
+ padding: 12px 14px;
+ font-size: 14px;
+ }
+ .pm-lograil {
+ display: block;
+ flex: 0 0 360px;
+ width: 360px;
+ overflow-y: auto;
+ border-left: 1px solid rgba(255, 255, 255, 0.08);
+ padding: 16px 14px;
+ }
+ .pm-bottomnav {
+ display: none;
+ }
+ .pm-main-inner {
+ max-width: 760px;
+ padding: 20px 20px 28px;
+ }
+ /* Log lives in the right rail on desktop, so its tab body is redundant */
+ .pm-log-tab {
+ display: none !important;
+ }
+}