This commit is contained in:
team3
2026-09-07 07:44:00 +02:00
parent 5d42b4cfc5
commit c6bbed4049
250 changed files with 13372 additions and 6203 deletions

BIN
frontend/chatui.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,16 +1,19 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { fetchProjekte } from './api.js'
import { fetchProjekte, fetchVerzeichnis } from './api.js'
import SeitenLeiste from './components/SeitenLeiste.vue'
import FeatureListe from './components/FeatureListe.vue'
import KontextMenue from './components/KontextMenue.vue'
import PruefPanel from './components/PruefPanel.vue'
import ChatPanel from './components/ChatPanel.vue'
import RecherchePanel from './components/RecherchePanel.vue'
import ModalBox from './components/ModalBox.vue'
import {
laeuft as planungLaeuft,
sicht as planungSicht,
anzahl as planungAnzahl,
meldung as planungMeldung,
agentLaeuft, agentMeldung, agentBericht, agentSchritte, agentAktivitaet, agentUebernommen,
lade as ladePlanung,
starte as startePlanung,
umsetzen as setzePlanungUm,
@@ -22,6 +25,7 @@ import {
prozent as pruefungProzent,
starte as startePruefung,
schliesse as schliessePruefung,
verwerfeAlles as verwerfeVorschlaege,
} from './composables/pruefung.js'
const projekte = ref([])
@@ -32,16 +36,57 @@ const meldung = ref('')
// wären zwei Panels für dieselbe Fläche.
const chatOffen = ref(false)
// In der Übersicht schaut man nur nach. Wer etwas ändern will, wechselt in die Planung.
const inPlanung = computed(() => planungLaeuft.value && planungSicht.value === 'planung')
const nurAnsehen = computed(() => planungLaeuft.value && !inPlanung.value)
// Drei Räume, und was in einem erlaubt ist, ist es im anderen nicht:
//
// Übersicht was das Projekt heute kann — nur ansehen
// Planung Karten entwickeln, mit dem Quelltext als Beleg
// Recherche im Web nachschlagen, ohne Karten und ohne Code
//
// Die Trennung ist keine Optik. Ein Planungsgespräch, das nebenbei im Netz sucht,
// zieht Fremdes unbemerkt in einen Entwurf; eine Recherche, die die Karten kennt,
// fängt an, sie umzuschreiben. Was aus einem Fund folgt, entscheidet die Person.
const REITER = [
{ id: 'uebersicht', text: 'Übersicht' },
{ id: 'planung', text: 'Planung' },
{ id: 'recherche', text: 'Recherche' },
]
const reiter = ref('uebersicht')
// Umlegen heißt: die andere Seite zeigen. Gibt es noch keine Planung, entsteht sie hier.
function schalte() {
if (!planungLaeuft.value) return startePlanung()
planungSicht.value = inPlanung.value ? 'uebersicht' : 'planung'
const inPlanung = computed(() => planungLaeuft.value && planungSicht.value === 'planung')
// Geändert wird nur in der Planung. In der Recherche stehen die Karten daneben, damit
// man beim Suchen sieht, worum es geht — angefasst wird dort nichts.
const nurAnsehen = computed(() =>
reiter.value === 'recherche' || (planungLaeuft.value && !inPlanung.value))
// Wer die Planung aufschlägt und noch keine hat, beginnt damit eine. Schlägt das
// fehl, bleibt der Reiter stehen, wo er war — sonst stünde man vor leerer Fläche.
async function zeige(ziel) {
if (ziel === 'planung' && !planungLaeuft.value) {
if (!await startePlanung()) return
}
reiter.value = ziel
}
// `planungSicht` steuert die Markierungen auf den Karten und wird auch von der
// Planung selbst gesetzt — etwa wenn nach einem Neustart eine offene dasteht.
// Beide Richtungen abgleichen, damit es nur einen Zustand gibt, den man sieht.
// Auch auf `planungLaeuft`: beim Projektwechsel liest die Planung neu und stellt
// die Sicht dabei selbst. Ohne den zweiten Auslöser stünde die Recherche danach vor
// dem Projektstand — bei diesem Projekt ein leeres Feld, weil alle Karten in der
// Planung liegen.
watch([reiter, planungLaeuft], ([r]) => {
// Nur die Übersicht zeigt den heutigen Stand. Die Recherche steht neben der
// Planung: gesucht wird zu dem, was gebaut werden soll, nicht zu dem, was war.
// Ohne laufende Planung sind ohnehin beide Sichten dasselbe Verzeichnis.
planungSicht.value = r === 'uebersicht' ? 'uebersicht' : 'planung'
})
watch(planungSicht, s => {
// Wer recherchiert, bleibt dabei: ein Projektwechsel im Hintergrund darf den
// laufenden Faden nicht wegziehen.
if (reiter.value === 'recherche') return
reiter.value = s === 'planung' ? 'planung' : 'uebersicht'
})
const stand = ref(0)
function oeffneChat() {
@@ -77,8 +122,8 @@ function umbenannt(alt, neu) {
ladeProjekte()
}
// Prüfung gehört zum Projekt — beim Wechsel wird sie verworfen
watch(aktivesProjekt, () => { if (pruefungOffen.value) schliessePruefung() })
// Prüfung und Vorschläge gehören zum Projekt — beim Wechsel wird alles verworfen
watch(aktivesProjekt, () => verwerfeVorschlaege())
watch(aktivesProjekt, n => ladePlanung(n))
// Planen heißt, die Karten mit der KI zu entwickeln. Das Gespräch ist dabei kein
@@ -93,8 +138,40 @@ watch(planungSicht, s => {
chatOffen.value = true
})
// Umsetzen und Abbrechen ändern die Karten — die Liste muss neu lesen.
// Die Recherche nimmt die ganze Breite: sie hat keine Karten daneben, an denen ein
// Panel etwas zu suchen hätte.
watch(reiter, r => {
if (r !== 'recherche') return
if (pruefungOffen.value) schliessePruefung()
chatOffen.value = false
})
// Erst wenn der Agent fertig ist, wurde wirklich übernommen — dann neu lesen.
watch(agentUebernommen, u => { if (u) stand.value++ })
// Jede Kartenänderung kann die Markierungen verschieben — die Planung liest nach.
// Sonst fehlt einer frisch angepassten Karte ihr „Geändert", bis jemand neu lädt.
function kartenGeaendert() {
if (planungLaeuft.value) ladePlanung(aktivesProjekt.value)
}
// Umsetzen überschreibt den Projektstand, Verwerfen wirft die Planung weg —
// beides gibt es nicht zurück. Ein Klick daneben wäre teuer, darum die Rückfrage.
const planungsFrage = ref(null) // 'umsetzen' | 'abbrechen'
// Die Rückfrage sagt ehrlich, was passiert: mit Arbeitsverzeichnis implementiert
// Claude die Änderungen gleich mit.
const verzeichnisPfad = ref('')
async function frageUmsetzen() {
verzeichnisPfad.value = ''
try {
verzeichnisPfad.value = (await fetchVerzeichnis(aktivesProjekt.value)).pfad
} catch { /* ohne Antwort bleibt die Frage schlicht */ }
planungsFrage.value = 'umsetzen'
}
async function planungBeenden(wie) {
planungsFrage.value = null
const ok = wie === 'umsetzen' ? await setzePlanungUm() : await brichPlanungAb()
if (ok) stand.value++
}
@@ -132,7 +209,10 @@ onMounted(async () => {
</script>
<template>
<div class="raster" :class="{ pruefung: pruefungOffen || chatOffen }">
<div
class="raster"
:class="{ pruefung: pruefungOffen || chatOffen || reiter === 'recherche' }"
>
<SeitenLeiste
:projekte="projekte"
:aktiv="aktivesProjekt"
@@ -142,28 +222,45 @@ onMounted(async () => {
/>
<main>
<!-- Ganz oben, über allem: der Raum, in dem man steht. Er entscheidet, was die
KI darunter darf, und das gehört nicht in eine Werkzeugleiste versteckt. -->
<nav v-if="aktivesProjekt" class="reiter">
<button
v-for="r in REITER"
:key="r.id"
:class="{ an: reiter === r.id }"
:title="r.id === 'planung' && !planungLaeuft ? 'Planung beginnen' : ''"
@click="zeige(r.id)"
>
{{ r.text }}
<span v-if="r.id === 'planung' && planungAnzahl" class="zahl">{{ planungAnzahl }}</span>
</button>
</nav>
<p v-if="meldung" class="meldung">{{ meldung }}</p>
<p v-if="planungMeldung" class="meldung">{{ planungMeldung }}</p>
<!-- Der Agent arbeitet auf der Planung; übernommen wird erst nach Erfolg.
Die Schritte kommen aus dem Plan und werden abgehakt, sobald er sie meldet. -->
<div v-if="agentLaeuft" class="agentzeile">
<p class="kopfzeile"><span class="puls" /> Claude implementiert die Planung …</p>
<ul class="schritte">
<li v-for="s in agentSchritte" :key="s.text" :class="{ fertig: s.fertig }">
<span class="haken">{{ s.fertig ? '✓' : '○' }}</span>{{ s.text }}
</li>
</ul>
<p v-if="agentAktivitaet" class="tut">{{ agentAktivitaet }}</p>
</div>
<div v-else-if="agentMeldung" class="agentzeile" :class="{ fehl: agentMeldung.startsWith('Agent:') }">
{{ agentMeldung }}
<details v-if="agentBericht"><summary>Bericht</summary><pre>{{ agentBericht }}</pre></details>
</div>
<FeatureListe
v-if="aktivesProjekt"
:projekt="aktivesProjekt"
:projekte="projekte"
:stand="stand"
@geaendert="kartenGeaendert"
>
<template #werkzeuge>
<!-- Ein Schalter, zwei Stellungen. Der Text nennt, was gerade zu sehen ist.
Er steht immer da: „wo bin ich" ist auch dann eine Frage, wenn noch
nichts geplant wird. Wer dann umlegt, beginnt eine Planung. -->
<button
class="schalter" :class="{ planung: inPlanung }"
:title="inPlanung ? 'Wechseln zu Übersicht'
: (planungLaeuft ? 'Wechseln zu Planung' : 'Planung beginnen')"
@click="schalte"
>
⇄ {{ inPlanung ? 'Planung' : 'Übersicht' }}
<span v-if="inPlanung && planungAnzahl" class="zahl">{{ planungAnzahl }}</span>
</button>
<!-- Jedes Werkzeug hier ändert Karten. Die Übersicht sagt nur, was das Projekt
heute kann — dort wird nichts entschieden und nichts angefasst. -->
<template v-if="!nurAnsehen">
@@ -175,8 +272,8 @@ onMounted(async () => {
>✎</button>
<button class="icon-knopf" title="Stil prüfen" @click="startePruefung(aktivesProjekt, 'stil')">⌾</button>
<template v-if="planungLaeuft">
<button class="icon-knopf" title="Planung umsetzen" @click="planungBeenden('umsetzen')">▶</button>
<button class="icon-knopf rot" title="Planung verwerfen" @click="planungBeenden('abbrechen')">✕</button>
<button class="icon-knopf" title="Planung umsetzen" @click="frageUmsetzen">▶</button>
<button class="icon-knopf rot" title="Planung verwerfen" @click="planungsFrage = 'abbrechen'">✕</button>
</template>
<span v-if="pruefungLaeuft" class="fortschritt">{{ pruefungProzent }} %</span>
</template>
@@ -189,13 +286,38 @@ onMounted(async () => {
<p v-else-if="!meldung" class="leer">Projekt wählen oder anlegen.</p>
</main>
<PruefPanel v-if="pruefungOffen" />
<!-- Eine rechte Spalte, drei mögliche Bewohner. Der Reiter entscheidet zuerst:
wer recherchiert, sieht die Recherche, nicht ein Panel von vorhin. -->
<RecherchePanel
v-if="reiter === 'recherche' && aktivesProjekt"
:projekt="aktivesProjekt"
/>
<PruefPanel v-else-if="pruefungOffen" />
<ChatPanel
v-else-if="chatOffen && aktivesProjekt"
:projekt="aktivesProjekt"
@schliessen="chatOffen = false"
/>
<KontextMenue />
<ModalBox
v-if="planungsFrage === 'umsetzen'"
titel="Planung umsetzen"
:frage="verzeichnisPfad
? `Claude implementiert die Änderungen in ${verzeichnisPfad}. Übernommen wird erst, wenn er fertig ist. Starten?`
: 'Die geplanten Karten werden der neue Projektstand. Umsetzen?'"
knopf="Umsetzen"
@bestaetigt="planungBeenden('umsetzen')"
@abbrechen="planungsFrage = null"
/>
<ModalBox
v-else-if="planungsFrage === 'abbrechen'"
titel="Planung verwerfen"
frage="Alle geplanten Änderungen gehen verloren. Verwerfen?"
knopf="Verwerfen"
@bestaetigt="planungBeenden('abbrechen')"
@abbrechen="planungsFrage = null"
/>
</div>
</template>
@@ -232,7 +354,8 @@ main {
.raster.pruefung > aside { grid-area: leiste; }
.raster.pruefung > main { grid-area: inhalt; }
.raster.pruefung > .panel,
.raster.pruefung > .chat { grid-area: panel; }
.raster.pruefung > .chat,
.raster.pruefung > .recherche { grid-area: panel; }
}
/* Schmale Fenster: die Projektliste legt sich als Leiste nach oben */
@@ -256,24 +379,76 @@ main {
}
.icon-knopf.an { color: var(--akzent); background: var(--code-bg); }
/* Ein Schalter statt zweier Reiter. Beschriftet mit dem, was zu sehen ist —
das Pfeilpaar sagt, dass ein Klick die andere Seite zeigt. */
.schalter {
border: 1px solid var(--rand);
/* Drei Reiter auf einer Linie. Der aktive trägt den Strich — wo man steht, liest
man an einer Kante ab, nicht an einer Färbung, die auch nur Betonung sein könnte. */
.reiter {
display: flex;
gap: 0.2rem;
border-bottom: 1px solid var(--rand);
margin: -0.2rem 0 1rem;
}
.reiter button {
border: none;
border-bottom: 2px solid transparent;
background: none;
color: var(--dezent);
border-radius: 0.4rem;
padding: 0.2rem 0.55rem;
margin-right: 0.3rem;
padding: 0.35rem 0.8rem;
margin-bottom: -1px;
cursor: pointer;
font: inherit;
font-size: 0.78rem;
font-size: 0.86rem;
white-space: nowrap;
}
.schalter.planung {
color: var(--akzent-text);
background: var(--akzent);
border-color: var(--akzent);
.reiter button:hover { color: var(--text); }
.reiter button.an {
color: var(--akzent);
border-bottom-color: var(--akzent);
font-weight: 600;
}
.reiter .zahl {
margin-left: 0.35rem;
font-size: 0.72rem;
font-weight: 700;
}
/* Der Agent meldet sich in einem Kasten über den Karten */
.agentzeile {
max-width: 100rem;
margin: 0 auto 0.7rem;
font-size: 0.85rem;
color: var(--dezent);
border: 1px solid var(--rand);
border-radius: 8px;
background: var(--panel);
padding: 0.5rem 0.8rem;
}
.agentzeile .kopfzeile { margin: 0; color: var(--text); }
.schritte { list-style: none; margin: 0.3rem 0 0; padding: 0; }
.schritte li { padding: 0.06rem 0; }
.schritte li.fertig { color: var(--gruen); }
.schritte .haken { display: inline-block; width: 1.2rem; }
.tut {
margin: 0.3rem 0 0;
font-size: 0.78rem;
font-family: ui-monospace, monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agentzeile.fehl { color: var(--rot); }
.agentzeile pre { white-space: pre-wrap; }
.agentzeile summary { cursor: pointer; color: var(--akzent); }
.puls {
display: inline-block;
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: var(--akzent);
animation: puls 1.2s ease-in-out infinite;
vertical-align: baseline;
}
@keyframes puls {
0%, 100% { opacity: 0.25; }
50% { opacity: 1; }
}
.schalter .zahl { margin-left: 0.3rem; font-weight: 700; }
</style>

View File

@@ -41,8 +41,6 @@ export const featureSpeichern = (projekt, id, roh) =>
req(`/projekte/${p(projekt)}/features/${p(id)}`, { method: 'PUT', body: { roh } })
export const featureLoeschen = (projekt, id) =>
req(`/projekte/${p(projekt)}/features/${p(id)}`, { method: 'DELETE' })
export const featureVerschieben = (projekt, id, ziel) =>
req(`/projekte/${p(projekt)}/features/${p(id)}/projekt`, { method: 'PUT', body: { ziel } })
export const pruefungStarten = (projekt, art = 'stil') =>
@@ -61,9 +59,23 @@ export const planungAbbrechen = (projekt) =>
export const planungZuruecksetzen = (projekt, titel) =>
req(`/projekte/${p(projekt)}/planung/zuruecksetzen`, { method: 'POST', body: { titel } })
export const fetchVerzeichnis = (projekt) => req(`/projekte/${p(projekt)}/verzeichnis`)
export const verzeichnisSetzen = (projekt, pfad) =>
req(`/projekte/${p(projekt)}/verzeichnis`, { method: 'PUT', body: { pfad } })
export const fetchAuftrag = (projekt) => req(`/projekte/${p(projekt)}/auftrag`)
export const fetchOrdner = (pfad = '') => req(`/system/ordner?pfad=${p(pfad)}`)
export const fetchChat = (projekt) => req(`/projekte/${p(projekt)}/chat`)
export const chatFragen = (projekt, text) =>
req(`/projekte/${p(projekt)}/chat`, { method: 'POST', body: { text } })
export const chatLeeren = (projekt) =>
req(`/projekte/${p(projekt)}/chat`, { method: 'DELETE' })
export const fetchRecherche = (projekt) => req(`/projekte/${p(projekt)}/recherche`)
export const rechercheAnlegen = (projekt) =>
req(`/projekte/${p(projekt)}/recherche`, { method: 'POST' })
export const rechercheFragen = (projekt, id, text) =>
req(`/projekte/${p(projekt)}/recherche/${p(id)}`, { method: 'POST', body: { text } })
export const rechercheSchliessen = (projekt, id) =>
req(`/projekte/${p(projekt)}/recherche/${p(id)}`, { method: 'DELETE' })

View File

@@ -158,6 +158,7 @@ input, textarea {
}
.badge.blau { color: var(--akzent); background: var(--code-bg); }
.badge.gruen { color: var(--gruen); background: var(--gruen-bg); }
.badge.gold { color: var(--gold); background: var(--gold-bg); }
pre {
background: var(--code-bg);

View File

@@ -1,8 +1,11 @@
<script setup>
import { nextTick, ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { marked } from 'marked'
import { fetchChat, chatFragen, chatLeeren } from '../api.js'
import { ergaenze } from '../composables/pruefung.js'
import { starteUhr, stoppeUhr } from '../composables/uhr.js'
import { lies, setze } from '../composables/entwuerfe.js'
import ModalBox from './ModalBox.vue'
// Das Gespräch ändert die Karten nicht selbst. Es legt Vorschläge daneben — dieselben,
// die auch die Prüfung erzeugt. Angenommen wird einzeln, auf der Karte.
@@ -13,8 +16,17 @@ const props = defineProps({
const emit = defineEmits(['schliessen'])
const verlauf = ref([])
const entwurf = ref('')
// Der Entwurf liegt außerhalb der Komponente: sie verschwindet beim Reiterwechsel,
// der halb getippte Satz soll es nicht.
const schluessel = computed(() => `chat:${props.projekt}`)
const entwurf = computed({
get: () => lies(schluessel.value),
set: (t) => setze(schluessel.value, t),
})
const laeuft = ref(false)
// Darf die KI nachschlagen, dauert eine Antwort statt Sekunden auch Minuten. Ohne
// laufende Zahl sieht ein langer Lauf aus wie ein hängender.
const sekunden = ref(0)
const meldung = ref('')
const liste = ref(null)
const feld = ref(null)
@@ -42,6 +54,8 @@ async function senden() {
if (!text || laeuft.value) return
meldung.value = ''
laeuft.value = true
sekunden.value = 0
starteUhr('chatUhr', () => (sekunden.value += 1), 1000)
// Die eigene Zeile sofort zeigen; auf die Antwort wartet man sonst vor leerem Feld
verlauf.value = [...verlauf.value, { rolle: 'person', text }]
entwurf.value = ''
@@ -55,12 +69,16 @@ async function senden() {
entwurf.value = text // nichts verlieren, wenn es schiefgeht
verlauf.value = verlauf.value.slice(0, -1)
}
stoppeUhr('chatUhr')
laeuft.value = false
nachUnten()
feld.value?.focus()
}
// Ein Klick löscht das ganze Gespräch — dafür gibt es eine Rückfrage.
const frage = ref(false)
async function leeren() {
frage.value = false
try {
await chatLeeren(props.projekt)
verlauf.value = []
@@ -80,7 +98,7 @@ watch(() => props.projekt, laden, { immediate: true })
class="icon-knopf"
:disabled="!verlauf.length || laeuft"
title="Gespräch verwerfen"
@click="leeren"
@click="frage = true"
>⌫</button>
<button class="icon-knopf" title="Gespräch schließen" @click="emit('schliessen')">✕</button>
</header>
@@ -93,24 +111,33 @@ watch(() => props.projekt, laden, { immediate: true })
<div v-if="e.rolle === 'ki'" class="inhalt" v-html="md(e.text)" />
<div v-else class="inhalt">{{ e.text }}</div>
</div>
<p v-if="laeuft" class="denkt">denkt nach …</p>
<p v-if="laeuft" class="denkt">denkt nach … {{ sekunden }} s</p>
</div>
<p v-if="meldung" class="meldung">{{ meldung }}</p>
<div class="eingabe">
<!-- Tippen bleibt möglich, während die Antwort läuft — nur Senden wartet -->
<textarea
ref="feld"
v-model="entwurf"
rows="3"
placeholder="Was soll das Projekt können?"
:disabled="laeuft"
@keydown.enter.exact.prevent="senden"
/>
<button class="primaer" :disabled="!entwurf.trim() || laeuft" @click="senden">
Senden
</button>
</div>
<ModalBox
v-if="frage"
titel="Gespräch verwerfen"
frage="Der ganze Gesprächsverlauf wird gelöscht. Verwerfen?"
knopf="Verwerfen"
@bestaetigt="leeren"
@abbrechen="frage = false"
/>
</aside>
</template>

View File

@@ -1,5 +1,5 @@
<script setup>
import { nextTick, ref, watch } from 'vue'
import { nextTick, onMounted, ref, watch } from 'vue'
import { marked } from 'marked'
import { featureSpeichern } from '../api.js'
import { oeffneMenue } from '../composables/kontextmenue.js'
@@ -12,14 +12,15 @@ const props = defineProps({
feature: { type: Object, required: true },
vollmodus: { type: Boolean, default: false }, // frisch angelegt: direkt im Vollmodus öffnen
probleme: { type: Array, default: () => [] },
verschiebbar: { type: Boolean, default: false }, // es gibt ein anderes Projekt
// {art, funktionen: {titel: 'neu'|'weg'|'geaendert'}, kopf} — null außerhalb der Planung
planung: { type: Object, default: null },
// Übersicht während einer Planung: der Projektstand ist Ansicht, nicht
// Arbeitsfläche. Ohne die Sperre landete jede Änderung still in der Kopie.
nurLesen: { type: Boolean, default: false },
// Eine andere Karte wurde geöffnet — höchstens eine ist groß
zuklappen: { type: Boolean, default: false },
})
const emit = defineEmits(['aktualisiert', 'entfernen', 'verschieben', 'zuruecksetzen'])
const emit = defineEmits(['aktualisiert', 'entfernen', 'zuruecksetzen', 'modus'])
const PLANUNG_TITEL = { neu: 'Neu', weg: 'Wird gestrichen', geaendert: 'Geändert' }
@@ -44,6 +45,7 @@ function kopie(sekt) {
}
}
const wurzel = ref(null) // die Karte selbst — zum Hinscrollen beim Aufklappen
const s = ref(kopie(props.feature.sektionen))
watch(() => props.feature, f => { s.value = kopie(f.sektionen) })
@@ -118,21 +120,9 @@ function funktionsMenue(e, i) {
// Kompakt wird gelesen, nicht angepasst. Sonst ließe sich hier eine Funktion
// entfernen, während die Karte sagt: Anpassen nur im Vollmodus.
if (!gross.value) return kartenMenue(e)
const eintraege = []
if (i > 0) eintraege.push({ text: '▲ Nach oben', aktion: () => schiebe(i, -1) })
if (i < s.value.funktionen.length - 1) {
eintraege.push({ text: '▼ Nach unten', aktion: () => schiebe(i, 1) })
}
eintraege.push({ text: '✕ Funktion entfernen', aktion: () => (modal.value = { art: 'entfernen', i }) })
oeffneMenue(e, eintraege)
}
async function schiebe(i, richtung) {
const liste = s.value.funktionen
const j = i + richtung
if (j < 0 || j >= liste.length) return
;[liste[i], liste[j]] = [liste[j], liste[i]]
await speichern()
oeffneMenue(e, [
{ text: '✕ Funktion entfernen', aktion: () => (modal.value = { art: 'entfernen', i }) },
])
}
function kartenMenue(e) {
@@ -141,9 +131,6 @@ function kartenMenue(e) {
if (gross.value) {
eintraege.push({ text: '+ Funktion erstellen', aktion: () => (modal.value = { art: 'erstellen' }) })
}
if (props.verschiebbar) {
eintraege.push({ text: '→ Feature verschieben', aktion: () => emit('verschieben') })
}
eintraege.push({ text: '✕ Feature entfernen', aktion: () => emit('entfernen') })
oeffneMenue(e, eintraege)
}
@@ -162,13 +149,22 @@ async function funktionEntfernen() {
}
function wechsleArt() {
if (props.nurLesen) return
if (s.value.art === 'vision') return // die Vision ist keine Einstufung
s.value.art = s.value.art === 'kern' ? 'optional' : 'kern'
speichern()
}
// Offen heißt: bestellt, aber noch nicht gebaut. Die Vorwärtsprüfung klagt eine
// offene Funktion nicht an — sie nimmt sie ab, sobald Code dazu belegt ist.
// kern blau, optional grün, vision gold
function badgeFarbe(art) {
return art === 'vision' ? 'gold' : art === 'kern' ? 'blau' : 'gruen'
}
// Offen heißt: noch nicht bewiesen. Zurücksetzen ist die Art, eine kaputte
// Umsetzung zu melden — der nächste Agentenlauf prüft die Funktion gegen ihre
// Testschritte und hakt sie erst ab, wenn sie stimmt.
function wechsleStand(i) {
if (props.nurLesen) return
const f = s.value.funktionen[i]
f.stand = f.stand === 'offen' ? 'gebaut' : 'offen'
speichern()
@@ -213,24 +209,49 @@ async function speichern() {
}
}
function zeilen(text) {
return Math.max(2, text.split('\n').length)
// Das Eingabefeld wächst mit dem Inhalt — auch mit umbrochenen Zeilen. Eine
// Zeilenzahl aus \n greift zu kurz: lange Zeilen brechen um, und der Text
// verschwand hinter einem Rollbalken.
function wachse(el) {
el.style.height = 'auto'
el.style.height = `${el.scrollHeight + 2}px`
}
const vWachsend = { mounted: wachse, updated: wachse }
// Blocktext („- Eintrag“ je Zeile) für die Listen-Anzeige zerlegen
function punkte(text) {
return text.split('\n').map(z => z.replace(/^[-*]\s*/, '').trim()).filter(Boolean)
}
// Kompaktmodus zum Lesen, Vollmodus zum Anpassen — MMB auf die Karte
// oder das Augen-Icon schaltet um
// Kompaktmodus zum Lesen, Vollmodus zum Anpassen. LMB auf die kompakte Karte
// öffnet sie, MMB schaltet in beide Richtungen. Es ist höchstens eine offen —
// die Liste klappt die anderen zu.
function wechsleAnsicht() {
gross.value = !gross.value
}
function oeffne() {
if (!gross.value) gross.value = true
}
watch(() => props.zuklappen, zu => { if (zu && gross.value) gross.value = false })
// Prüfung: fehlerhafte Karten öffnen sich, damit die Stellen sichtbar sind
watch(() => props.probleme.length, n => { if (n) gross.value = true }, { immediate: true })
// Die Liste ordnet aufgeklappte Karten an den Anfang ihrer Zeile — dafür muss
// sie wissen, welche gerade groß sind. Danach holt die Seite die Karte ins Bild:
// nach dem Umsortieren steht sie sonst außerhalb des Sichtbereichs.
async function insBild() {
await nextTick()
wurzel.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
watch(gross, g => {
emit('modus', g)
if (g) insBild()
}, { immediate: true })
onMounted(() => { if (gross.value) insBild() })
function problemeAn(feld, funktion = '', original = null) {
return props.probleme.filter(p =>
p.feld === feld &&
@@ -245,6 +266,12 @@ function problemeFeld(feld, funktion = '') {
p.feld === feld && (p.funktion || '') === funktion && p.ganzes_feld)
}
// Ein leeres Feld wird nicht gerendert — ein Vorschlag dazu hätte sonst keinen Ort,
// und man müsste blind annehmen. Also den Block auch für den Vorschlag aufmachen.
function hatVorschlag(feld, funktion = '') {
return problemeAn(feld, funktion).length > 0 || problemeFeld(feld, funktion).length > 0
}
// Bleibt die alte Zeile im Vorschlag stehen, kommt nur etwas dazu — sie ist in Ordnung
function fehlerAn(feld, funktion = '', original = null) {
return problemeAn(feld, funktion, original).some(p => vorschlagsArt(p) !== 'ergaenzen')
@@ -343,9 +370,11 @@ function alleAblehnen() {
<template>
<article
ref="wurzel"
class="karte"
:data-feature="feature.id"
:class="[s.art, { gross, fehlerhaft: probleme.length }]"
@click="oeffne"
@mousedown.middle.prevent
@auxclick.middle.prevent="wechsleAnsicht"
@contextmenu.prevent.stop="kartenMenue"
@@ -362,22 +391,17 @@ function alleAblehnen() {
>
<h3 v-else :title="gross ? 'Klicken zum Bearbeiten' : null" @click="starte('titel')">{{ s.titel }}</h3>
<button
v-if="gross"
v-if="gross && s.art !== 'vision'"
class="badge"
:class="[s.art === 'kern' ? 'blau' : 'gruen', { fehler: fehlerAn('badge') }]"
:class="[badgeFarbe(s.art), { fehler: fehlerAn('badge') }]"
title="Klicken zum Umschalten"
@click="wechsleArt"
>{{ s.art }}</button>
<span
v-else
class="badge"
:class="[s.art === 'kern' ? 'blau' : 'gruen', { fehler: fehlerAn('badge') }]"
:class="[badgeFarbe(s.art), { fehler: fehlerAn('badge') }]"
>{{ s.art }}</span>
<button
class="icon-knopf"
:title="gross ? 'Kompaktmodus' : 'Vollmodus'"
@click="wechsleAnsicht"
>👁</button>
<span v-if="probleme.length" class="alle">
<span class="anzahl">{{ probleme.length }}</span>
<button
@@ -407,7 +431,8 @@ function alleAblehnen() {
v-if="istAktiv('ziel')"
ref="feldRef"
v-model="entwurf"
:rows="zeilen(entwurf)"
v-wachsend
rows="2"
@blur="fertig"
@keydown.esc="abbrechen"
/>
@@ -424,11 +449,12 @@ function alleAblehnen() {
v-if="istAktiv('regeln')"
ref="feldRef"
v-model="entwurf"
:rows="zeilen(entwurf)"
v-wachsend
rows="2"
@blur="fertig"
@keydown.esc="abbrechen"
/>
<div v-else-if="s.regeln" class="klickbar" @click="starte('regeln')">
<div v-else-if="s.regeln" class="klickbar regelliste" @click="starte('regeln')">
<template v-for="(r, k) in punkte(s.regeln)" :key="k">
<div class="regel" :class="{ fehler: fehlerAn('regeln', '', r) }">{{ r }}</div>
<ProblemHinweis
@@ -477,7 +503,7 @@ function alleAblehnen() {
v-if="gross"
class="stand"
:class="{ offen: f.stand === 'offen', fehler: fehlerAn('stand', f.text) }"
:title="f.stand === 'offen' ? 'Bestellt, noch nicht gebaut' : 'Gebaut'"
:title="f.stand === 'offen' ? 'Noch nicht bewiesen — Claude prüft sie beim nächsten Lauf' : 'Gebaut und geprüft'"
@click.stop="wechsleStand(i)"
>{{ f.stand === 'offen' ? 'offen' : '✓' }}</button>
<span v-else-if="f.stand === 'offen'" class="stand offen">offen</span>
@@ -488,30 +514,40 @@ function alleAblehnen() {
<!-- Ziel, Regeln und Testen nur im Vollmodus -->
<div class="details">
<!-- Ziel der Funktion -->
<div v-if="istAktiv('ziel', i) || f.ziel" class="block">
<div v-if="istAktiv('ziel', i) || f.ziel || hatVorschlag('ziel', f.text)" class="block">
<p class="mini">Ziel</p>
<ProblemHinweis :probleme="problemeFeld('ziel', f.text)" @annehmen="annehmen" @ablehnen="ablehnen" />
<textarea
v-if="istAktiv('ziel', i)"
ref="feldRef"
v-model="entwurf"
:rows="zeilen(entwurf)"
v-wachsend
rows="2"
@blur="fertig"
@keydown.esc="abbrechen"
/>
<div v-else class="inhalt klickbar" v-html="md(f.ziel)" @click="starte('ziel', i)" />
<div v-else-if="f.ziel" class="inhalt klickbar" v-html="md(f.ziel)" @click="starte('ziel', i)" />
<ProblemHinweis :probleme="problemeAn('ziel', f.text)" @annehmen="annehmen" @ablehnen="ablehnen" />
</div>
<button v-if="!f.ziel && !istAktiv('ziel', i)" class="hinzu" @click="starte('ziel', i)">+ Ziel</button>
<button
v-if="!f.ziel && !istAktiv('ziel', i) && !hatVorschlag('ziel', f.text)"
class="hinzu"
@click="starte('ziel', i)"
>+ Ziel</button>
<!-- Regeln als Punktliste, ein Block je Funktion -->
<div v-if="istAktiv('regeln', i) || f.regeln" class="block">
<div
v-if="istAktiv('regeln', i) || f.regeln || hatVorschlag('regeln', f.text)"
class="block"
>
<p class="mini">Regeln</p>
<ProblemHinweis :probleme="problemeFeld('regeln', f.text)" @annehmen="annehmen" @ablehnen="ablehnen" />
<textarea
v-if="istAktiv('regeln', i)"
ref="feldRef"
v-model="entwurf"
:rows="zeilen(entwurf)"
v-wachsend
rows="2"
@blur="fertig"
@keydown.esc="abbrechen"
/>
@@ -526,17 +562,25 @@ function alleAblehnen() {
</template>
</div>
</div>
<button v-if="!f.regeln && !istAktiv('regeln', i)" class="hinzu" @click="starte('regeln', i)">+ Regeln</button>
<button
v-if="!f.regeln && !istAktiv('regeln', i) && !hatVorschlag('regeln', f.text)"
class="hinzu"
@click="starte('regeln', i)"
>+ Regeln</button>
<!-- Testen als nummerierte Schritte -->
<div v-if="istAktiv('testen', i) || f.testen" class="block">
<div
v-if="istAktiv('testen', i) || f.testen || hatVorschlag('testen', f.text)"
class="block"
>
<p class="mini">Testen</p>
<ProblemHinweis :probleme="problemeFeld('testen', f.text)" @annehmen="annehmen" @ablehnen="ablehnen" />
<textarea
v-if="istAktiv('testen', i)"
ref="feldRef"
v-model="entwurf"
:rows="zeilen(entwurf)"
v-wachsend
rows="2"
@blur="fertig"
@keydown.esc="abbrechen"
/>
@@ -554,7 +598,11 @@ function alleAblehnen() {
</div>
</div>
<button v-if="!f.testen && !istAktiv('testen', i)" class="hinzu" @click="starte('testen', i)">+ Testen</button>
<button
v-if="!f.testen && !istAktiv('testen', i) && !hatVorschlag('testen', f.text)"
class="hinzu"
@click="starte('testen', i)"
>+ Testen</button>
</div>
</div>
</div>
@@ -611,6 +659,7 @@ function alleAblehnen() {
.karte.kern { border-color: var(--akzent); }
.karte.optional { border-color: var(--gruen); }
.karte.vision { border-color: var(--gold); }
/* Prüfung: fehlerhafte Karte und Stelle rot */
.karte.fehlerhaft { border-color: var(--rot); }
/* Kurz hervorheben, wenn das Panel hierher springt */
@@ -656,10 +705,19 @@ function alleAblehnen() {
}
.icon-knopf.uebernehmen:hover { color: var(--gruen); background: var(--gruen-bg); }
/* Vollmodus: volle Breite, Funktionen als Boxen in responsiven Spalten,
alle Details offen */
.karte.gross { column-span: all; display: block; }
.karte.gross .liste { columns: 320px; column-gap: 1.2rem; }
/* Vollmodus: Funktionen als Boxen in responsiven Spalten, alle Details offen.
Beim Aufklappen dockt die Karte oben an — mit etwas Luft zum Rand. */
.karte.gross { scroll-margin-top: 0.6rem; }
/* Gitter, nicht Spalten: eine Funktionsbox ist unteilbar und hoch. Mit `columns`
verteilte der Browser zwei Boxen auf Spur 1 und 3 und riss eine Lücke von einer
halben Kartenbreite auf. Ein Gitter setzt sie deterministisch nebeneinander. */
.karte.gross .liste {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
gap: 0 1.2rem;
align-items: start;
}
.karte.gross .regelliste { columns: 22rem; }
.karte.gross section.leer { display: block; }
.karte.gross .funktion {
break-inside: avoid;
@@ -736,7 +794,15 @@ section.leer { display: none; }
}
.zeile .inhalt, .zeile input, .zeile textarea { flex: 1; min-width: 0; }
/* Funktionsliste */
/* Funktionsliste und Karten-Regeln laufen in Spalten — eine lange Liste ließ
die halbe Karte leer. Kompakt stehen nur Titel, da passen zwei Spalten immer.
Offen entscheidet die Breite: `columns: 2` riss auf einer vollbreiten Karte
zwei 780px-Spalten für 370px-Boxen auf. */
.liste { columns: 2; column-gap: 0.8rem; }
.funktion { break-inside: avoid; }
.regelliste { columns: 2; column-gap: 0.8rem; }
.regelliste .regel, .regelliste .hinweis-block { break-inside: avoid; }
.zeile.kopf { padding: 0.15rem 0.2rem; border-radius: 4px; }
.karte.gross .zeile.kopf:hover { background: var(--code-bg); }
.funktionstitel { flex: 1; }
@@ -785,15 +851,19 @@ section.leer { display: none; }
margin: 0.05rem 0;
}
/* Dieselbe Schrift wie der gelesene Text: was man tippt, sieht aus wie das,
was nachher dasteht. Kein Rollbalken, kein Ziehgriff — das Feld wächst selbst. */
textarea {
width: 100%;
resize: vertical;
font-family: ui-monospace, monospace;
font-size: 0.85rem;
resize: none;
overflow: hidden;
font: inherit;
font-size: 0.9rem;
line-height: 1.5;
}
/* Karte: MMB schaltet den Modus um; Texte sind nur im Vollmodus bearbeitbar */
/* Karte: LMB öffnet, MMB schaltet um; Texte sind nur im Vollmodus bearbeitbar */
.karte { cursor: pointer; }
.karte.gross { cursor: default; }
.klickbar { user-select: none; }
.karte.gross .klickbar, .karte.gross h3 { cursor: text; }
.inhalt.klickbar { border-radius: 4px; }

View File

@@ -1,11 +1,10 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { fetchFeatures, featureAnlegen, featureLoeschen, featureVerschieben } from '../api.js'
import { computed, onMounted, ref, watch } from 'vue'
import { fetchFeatures, featureAnlegen, featureLoeschen } from '../api.js'
import { oeffneMenue } from '../composables/kontextmenue.js'
import { probleme, problemeFuer, erledige } from '../composables/pruefung.js'
import { laeuft as planungLaeuft, aenderungen, sicht, fuerKarte, setzeZurueck } from '../composables/planung.js'
import FeatureKarte from './FeatureKarte.vue'
import VisionKarte from './VisionKarte.vue'
import ModalBox from './ModalBox.vue'
const props = defineProps({
@@ -19,7 +18,7 @@ const emit = defineEmits(['geaendert'])
const features = ref([])
const meldung = ref('')
const suche = ref('')
const modal = ref(null) // {art: 'erstellen'} | {art: 'entfernen'|'verschieben', feature}
const modal = ref(null) // {art: 'erstellen'} | {art: 'entfernen', feature}
const neuKey = ref(null) // frisch angelegte Karte: direkt im Vollmodus öffnen
// Stabiler Key je Karte: die id ändert sich mit dem Titel, ein Wechsel würde
@@ -27,8 +26,13 @@ const neuKey = ref(null) // frisch angelegte Karte: direkt im Vollmodus öffnen
let letzterKey = 0
const mitKey = (f) => ({ ...f, key: ++letzterKey })
// Vision zuerst, dann kern, dann optional — innerhalb der Gruppe alphabetisch.
// Das Wichtigste steht oben links, ohne dass jemand sortieren muss.
const RANG = { vision: 0, kern: 1, optional: 2 }
function sortiere() {
features.value.sort((a, b) =>
(RANG[a.sektionen.art] ?? 1) - (RANG[b.sektionen.art] ?? 1) ||
a.sektionen.titel.localeCompare(b.sektionen.titel, 'de', { sensitivity: 'base' }))
}
@@ -37,6 +41,7 @@ async function laden() {
neuKey.value = null
try {
features.value = (await fetchFeatures(props.projekt, sicht.value)).map(mitKey)
sortiere()
} catch (e) {
features.value = []
meldung.value = e.message
@@ -51,8 +56,7 @@ function aktualisiert(altId, f) {
emit('geaendert')
}
// Die Vision steht über den Features, nicht zwischen ihnen — eigene Zeile, goldener
// Rand, und höchstens eine je Projekt.
// Höchstens eine Vision je Projekt — nur fürs Kontextmenü interessant
const vision = computed(() => features.value.find(f => f.sektionen.art === 'vision'))
// Suche über alles, was auf der Karte steht — Titel, Ziel, Regeln, Funktionen.
@@ -66,11 +70,43 @@ function passt(f) {
return text.join(' ').toLowerCase().includes(wort)
}
const karten = computed(() =>
features.value.filter(f => f.sektionen.art !== 'vision' && passt(f)))
const sichtbareVision = computed(() => (vision.value && passt(vision.value)) ? vision.value : null)
const versteckt = computed(() =>
features.value.length - karten.value.length - (sichtbareVision.value ? 1 : 0))
const karten = computed(() => features.value.filter(passt))
// Eine aufgeklappte Karte nimmt die volle Breite — aber in ihrer eigenen Zeile,
// nicht am Anfang des Gitters. Dafür wird die Zeile aus der Kompaktordnung
// errechnet und die große Karte vor ihre Zeilennachbarn gestellt.
const gitterEl = ref(null)
const spalten = ref(3)
const grosse = ref(new Set())
// Höchstens eine Karte offen: wer öffnet, schließt die anderen
const offen = ref(null)
function setzeGross(key, g) {
const s = new Set(grosse.value)
g ? s.add(key) : s.delete(key)
grosse.value = s
if (g) offen.value = key
else if (offen.value === key) offen.value = null
}
function messeSpalten() {
if (!gitterEl.value) return
spalten.value = getComputedStyle(gitterEl.value).gridTemplateColumns.split(' ').length
}
onMounted(() => {
messeSpalten()
new ResizeObserver(messeSpalten).observe(gitterEl.value)
})
const anzeige = computed(() => {
const S = Math.max(1, spalten.value)
return karten.value
.map((f, i) => ({ f, reihe: Math.floor(i / S), gross: grosse.value.has(f.key) }))
.sort((a, b) => a.reihe - b.reihe || b.gross - a.gross)
.map(x => x.f)
})
const versteckt = computed(() => features.value.length - karten.value.length)
function bereichsMenue(e) {
const eintraege = [
@@ -125,7 +161,15 @@ async function entfernen() {
// Eine vorgeschlagene Karte gibt es noch nicht — sie kann an keiner Karte hängen.
// Darum steht sie als eigener Umriss im Gitter, mit demselben ✓ und ✕.
const vorschlaege = computed(() => probleme.value.filter(p => p.feld === 'neue_karte'))
const vorschlaege = computed(() =>
nurLesen.value ? [] : probleme.value.filter(p => p.feld === 'neue_karte'))
const punkte = (text) => (text || '').split('\n').map(z => z.replace(/^-\s*/, '').trim())
.filter(Boolean)
function badgeFarbe(art) {
return art === 'vision' ? 'gold' : art === 'kern' ? 'blau' : 'gruen'
}
async function karteAnnehmen(p) {
try {
@@ -159,22 +203,6 @@ async function zurueck(titel) {
}
}
// Andere Projekte als Ziel; ins eigene zu verschieben ergäbe keinen Sinn
const andereProjekte = computed(() =>
props.projekte.map(p => p.name).filter(n => n !== props.projekt))
async function verschieben(ziel) {
const id = modal.value.feature.id
try {
await featureVerschieben(props.projekt, id, ziel)
features.value = features.value.filter(f => f.id !== id)
modal.value = null
emit('geaendert')
} catch (e) {
meldung.value = e.message
}
}
watch(() => props.projekt, laden, { immediate: true })
watch(() => props.stand, laden)
// Der Reiter entscheidet, welchen Ordner der Server liest — also neu laden.
@@ -204,38 +232,56 @@ const nurLesen = computed(() => planungLaeuft.value && sicht.value === 'uebersic
</button>
</p>
<div class="gitter">
<div ref="gitterEl" class="gitter">
<article v-for="p in vorschlaege" :key="p.vorschlag" class="karte umriss">
<header>
<h3>{{ p.vorschlag }}</h3>
<span v-if="p.sektionen" class="badge" :class="badgeFarbe(p.sektionen.art)">
{{ p.sektionen.art }}
</span>
<button class="icon-knopf uebernehmen" title="Karte anlegen" @click="karteAnnehmen(p)">✓</button>
<button class="icon-knopf rot" title="Vorschlag verwerfen" @click="erledige(p)">✕</button>
</header>
<p class="hinweis">Vorgeschlagene Karte</p>
<!-- Der ganze Inhalt, nicht nur der Titel: ob eine Karte taugt, entscheidet
sich an ihren Funktionen. -->
<template v-if="p.sektionen">
<section v-if="p.sektionen.ziel">
<p class="gruppe">Ziel</p>
<p class="inhalt">{{ p.sektionen.ziel }}</p>
</section>
<section v-if="punkte(p.sektionen.regeln).length">
<p class="gruppe">Regeln</p>
<div v-for="(r, k) in punkte(p.sektionen.regeln)" :key="k" class="regel">{{ r }}</div>
</section>
<section v-if="p.sektionen.funktionen.length">
<p class="gruppe">Funktionen</p>
<div v-for="(f, k) in p.sektionen.funktionen" :key="k" class="funktion">
<p class="ftitel">{{ f.text }}</p>
<p v-if="f.ziel" class="inhalt">{{ f.ziel }}</p>
<div v-for="(r, j) in punkte(f.regeln)" :key="j" class="regel">{{ r }}</div>
<ol v-if="punkte(f.testen).length" class="testen">
<li v-for="(t, j) in punkte(f.testen)" :key="j">{{ t }}</li>
</ol>
</div>
</section>
</template>
</article>
<VisionKarte
v-if="sichtbareVision"
:key="sichtbareVision.key"
:projekt="projekt"
:feature="sichtbareVision"
@aktualisiert="aktualisiert"
@entfernen="oeffneModal({ art: 'entfernen', feature: sichtbareVision })"
/>
<FeatureKarte
v-for="f in karten"
v-for="f in anzeige"
:key="f.key"
:projekt="projekt"
:feature="f"
:vollmodus="f.key === neuKey"
:probleme="problemeFuer(f.id)"
:verschiebbar="andereProjekte.length > 0"
:probleme="nurLesen ? [] : problemeFuer(f.id)"
:planung="fuerKarte(f.sektionen.titel)"
:nur-lesen="nurLesen"
@aktualisiert="aktualisiert"
@entfernen="oeffneModal({ art: 'entfernen', feature: f })"
@verschieben="oeffneModal({ art: 'verschieben', feature: f })"
:zuklappen="offen !== null && offen !== f.key"
@zuruecksetzen="zurueck(f.sektionen.titel)"
@modus="setzeGross(f.key, $event)"
/>
<!-- Weggeplante Karten stehen in keiner Datei mehr. Ohne eigenen Umriss
@@ -248,7 +294,7 @@ const nurLesen = computed(() => planungLaeuft.value && sicht.value === 'uebersic
<p class="hinweis">Wird gestrichen</p>
</article>
</div>
<p v-if="!karten.length && !sichtbareVision && !meldung" class="leer">
<p v-if="!karten.length && !meldung" class="leer">
{{ suche.trim() ? 'Nichts gefunden.' : 'Noch keine Features. Rechtsklick zum Anlegen.' }}
</p>
@@ -260,15 +306,6 @@ const nurLesen = computed(() => planungLaeuft.value && sicht.value === 'uebersic
@bestaetigt="erstellen"
@abbrechen="modal = null"
/>
<ModalBox
v-else-if="modal && modal.art === 'verschieben'"
titel="Feature verschieben"
:auswahl="andereProjekte"
knopf="Verschieben"
:meldung="meldung"
@bestaetigt="verschieben"
@abbrechen="modal = null"
/>
<ModalBox
v-else-if="modal"
:titel="`${modal.feature.sektionen.titel === 'Vision' ? 'Vision' : 'Feature'} entfernen`"
@@ -282,7 +319,7 @@ const nurLesen = computed(() => planungLaeuft.value && sicht.value === 'uebersic
</template>
<style scoped>
.bereich { min-height: 100%; }
.bereich { min-height: 100%; container-type: inline-size; }
.kopf {
display: flex;
align-items: center;
@@ -320,23 +357,64 @@ const nurLesen = computed(() => planungLaeuft.value && sicht.value === 'uebersic
.umriss header { display: flex; align-items: center; gap: 0.3rem; }
.umriss h3 { flex: 1; margin: 0; font-size: 0.95rem; }
.umriss .hinweis { margin: 0.3rem 0 0; font-size: 0.8rem; color: var(--dezent); }
/* Inhalt des Vorschlags — dieselbe Gliederung wie auf einer echten Karte */
.umriss section { margin-top: 0.55rem; }
.umriss .gruppe {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--akzent);
margin: 0 0 0.15rem;
}
.umriss .inhalt { margin: 0; font-size: 0.9rem; }
.umriss .regel {
position: relative;
padding-left: 1.15rem;
font-size: 0.9rem;
}
.umriss .regel::before {
content: "•";
position: absolute;
left: 0.35rem;
color: var(--dezent);
}
.umriss .funktion {
border: 1px solid var(--rand);
border-radius: 8px;
padding: 0.35rem 0.6rem 0.45rem;
margin-bottom: 0.5rem;
}
.umriss .ftitel { margin: 0 0 0.1rem; font-weight: 600; font-size: 0.9rem; }
.umriss .testen {
margin: 0.25rem 0 0;
padding-left: 1.3rem;
font-size: 0.9rem;
color: var(--dezent);
}
.uebernehmen:hover { color: var(--gruen); background: var(--gruen-bg); }
/* Spaltenlayout: unterschiedlich hohe Karten stapeln sich ohne Lücken.
Die Breite ist gedeckelt — auf sehr breiten Schirmen klumpten die Karten sonst
links und rechts blieb eine tote Fläche. */
/* Raster: gleiche Spuren, ruhige Reihen. Die Breite ist gedeckelt — auf sehr
breiten Schirmen klumpten die Karten sonst links und rechts blieb tote Fläche.
`dense` füllt die Lücke, die eine doppelt breite Karte hinterlässt. */
.gitter {
columns: 22rem;
column-gap: 0.9rem;
display: grid;
/* Drei Spuren: breit genug zum Lesen, schmal genug für den Überblick.
Karten füllen ihre Reihe — gleiche Höhe je Zeile statt ausgefranster Kanten. */
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.9rem;
grid-auto-flow: dense;
max-width: 100rem;
margin: 0 auto;
}
.gitter > * {
/* inline-block macht die Karte atomar — Firefox fragmentiert sie sonst
trotz break-inside: avoid mitten im Inhalt */
break-inside: avoid;
display: inline-block;
vertical-align: top;
margin-bottom: 0.9rem;
width: 100%;
.gitter > * { min-width: 0; }
/* Vollmodus: volle Breite in der eigenen Zeile. Wo die Zeile beginnt, regelt
die Liste über die Reihenfolge — CSS kennt die Zeilen nicht. */
.gitter > :deep(.karte.gross) { grid-column: 1 / -1; }
/* Wird der Platz eng, fallen die Spuren */
@container (max-width: 66rem) {
.gitter { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@container (max-width: 44rem) {
.gitter { grid-template-columns: minmax(0, 1fr); }
}
</style>

View File

@@ -0,0 +1,101 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { fetchOrdner } from '../api.js'
// Ein Verzeichnis auswählen, ohne den Pfad zu kennen: durchblättern statt tippen.
// Das Feld bleibt trotzdem — wer den Pfad hat, fügt ihn ein und drückt Enter.
const props = defineProps({
vorgabe: { type: String, default: '' },
meldung: { type: String, default: '' }, // Fehler des Aufrufers (z. B. beim Speichern)
})
const emit = defineEmits(['bestaetigt', 'abbrechen'])
const pfad = ref('')
const ordner = ref([])
const eltern = ref('')
const fehler = ref('')
async function lade(ziel) {
fehler.value = ''
try {
const antwort = await fetchOrdner(ziel)
pfad.value = antwort.pfad
ordner.value = antwort.ordner
eltern.value = antwort.eltern
} catch (e) {
fehler.value = e.message
}
}
function beiTaste(e) {
if (e.key === 'Escape') emit('abbrechen')
}
onMounted(() => {
lade(props.vorgabe)
window.addEventListener('keydown', beiTaste)
})
onUnmounted(() => window.removeEventListener('keydown', beiTaste))
</script>
<template>
<div class="schleier" @click.self="emit('abbrechen')">
<div class="modal karte wahl">
<h2>Verzeichnis festlegen</h2>
<input
v-model="pfad"
placeholder="/pfad/zum/projekt"
@keyup.enter="lade(pfad)"
>
<div class="liste">
<button v-if="eltern" class="eintrag hoch" @click="lade(eltern)">↑ übergeordnet</button>
<button
v-for="o in ordner"
:key="o"
class="eintrag"
@click="lade(`${pfad}/${o}`)"
>▸ {{ o }}</button>
<p v-if="!ordner.length" class="leer">Keine Unterordner</p>
</div>
<p v-if="fehler || meldung" class="meldung">{{ fehler || meldung }}</p>
<footer>
<button @click="emit('bestaetigt', '')">Zurücksetzen</button>
<span class="platz" />
<button @click="emit('abbrechen')">Abbrechen</button>
<button class="primaer" :disabled="!pfad" @click="emit('bestaetigt', pfad)">
Übernehmen
</button>
</footer>
</div>
</div>
</template>
<style scoped>
.wahl { width: min(34rem, 92vw); }
.liste {
max-height: 40vh;
overflow-y: auto;
overscroll-behavior: contain;
border: 1px solid var(--rand);
border-radius: 6px;
padding: 0.2rem;
}
.eintrag {
display: block;
width: 100%;
border: none;
background: none;
text-align: left;
padding: 0.25rem 0.55rem;
border-radius: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eintrag:hover { background: var(--bg); }
.hoch { color: var(--dezent); }
.leer { margin: 0.3rem 0.5rem; color: var(--dezent); font-size: 0.85rem; }
footer { display: flex; gap: 0.5rem; }
.platz { flex: 1; }
</style>

View File

@@ -0,0 +1,339 @@
<script setup>
import { computed, nextTick, ref, watch } from 'vue'
import { marked } from 'marked'
import {
fetchRecherche, rechercheAnlegen, rechercheFragen, rechercheSchliessen,
} from '../api.js'
import { starteUhr, stoppeUhr } from '../composables/uhr.js'
import { lies, setze } from '../composables/entwuerfe.js'
import ModalBox from './ModalBox.vue'
// Ein Faden ist ein Thema. Mehrere nebeneinander, weil eine Suche nach „Upload zu S3"
// nichts mit einer nach „Diagrammbibliothek" zu tun hat — im selben Verlauf zöge das
// Modell den einen Kontext in die andere Antwort.
//
// Fäden laufen unabhängig: wer eine Frage abschickt und den Reiter wechselt, wartet
// weiter. Deshalb hängt alles Laufende an der Faden-Id, nicht an der Ansicht.
const props = defineProps({
projekt: { type: String, required: true },
})
const faeden = ref([])
const aktiv = ref('')
const meldung = ref('')
const liste = ref(null)
const feld = ref(null)
// Je Faden ein eigener Startzeitpunkt — mehrere können gleichzeitig suchen.
const seit = ref({})
const jetzt = ref(Date.now())
const UHR = '__planer2RechercheUhr'
const faden = computed(() => faeden.value.find(f => f.id === aktiv.value) || null)
const laeuft = computed(() => aktiv.value in seit.value)
// Der Entwurf liegt außerhalb der Komponente: sie verschwindet beim Reiterwechsel,
// der halb getippte Satz soll es nicht.
const schluessel = computed(() => `recherche:${props.projekt}:${aktiv.value}`)
const entwurf = computed({
get: () => lies(schluessel.value),
set: (t) => setze(schluessel.value, t),
})
const sekunden = computed(() =>
Math.floor((jetzt.value - (seit.value[aktiv.value] || jetzt.value)) / 1000))
const md = (t) => marked.parse(t)
// Die Uhr läuft nur, solange irgendwo gewartet wird
watch(() => Object.keys(seit.value).length, (n) => {
if (n) starteUhr(UHR, () => (jetzt.value = Date.now()), 1000)
else stoppeUhr(UHR)
})
async function nachUnten() {
await nextTick()
if (liste.value) liste.value.scrollTop = liste.value.scrollHeight
}
async function laden() {
meldung.value = ''
seit.value = {}
try {
faeden.value = (await fetchRecherche(props.projekt)).faeden
} catch (e) {
faeden.value = []
meldung.value = e.message
}
if (!faeden.value.some(f => f.id === aktiv.value)) {
aktiv.value = faeden.value[0]?.id || ''
}
nachUnten()
}
async function neuerFaden() {
meldung.value = ''
try {
const neu = await rechercheAnlegen(props.projekt)
faeden.value = [...faeden.value, neu]
aktiv.value = neu.id
} catch (e) {
meldung.value = e.message
return
}
await nextTick()
feld.value?.focus()
}
async function senden() {
const id = aktiv.value
const text = entwurf.value.trim()
if (!text || !id || id in seit.value) return
meldung.value = ''
seit.value = { ...seit.value, [id]: Date.now() }
jetzt.value = Date.now()
// Die eigene Zeile sofort zeigen; auf die Antwort wartet man sonst vor leerem Feld
faeden.value = faeden.value.map(f => f.id === id
? { ...f, verlauf: [...f.verlauf, { rolle: 'person', text }] } : f)
setze(`recherche:${props.projekt}:${id}`, '')
nachUnten()
try {
const antwort = await rechercheFragen(props.projekt, id, text)
faeden.value = faeden.value.map(f => (f.id === id ? antwort : f))
} catch (e) {
meldung.value = e.message
// Nichts verlieren: der Entwurf kommt zurück, die vorgezogene Zeile weg
setze(`recherche:${props.projekt}:${id}`, text)
faeden.value = faeden.value.map(f => f.id === id
? { ...f, verlauf: f.verlauf.slice(0, -1) } : f)
}
const { [id]: _weg, ...rest } = seit.value
seit.value = rest
if (aktiv.value === id) {
nachUnten()
feld.value?.focus()
}
}
// Ein leerer Faden verschwindet ohne Rückfrage — es geht nichts verloren.
const frage = ref(null)
function schliessen(f) {
if (f.verlauf.length) frage.value = f
else entferne(f.id)
}
async function entferne(id) {
frage.value = null
try {
await rechercheSchliessen(props.projekt, id)
} catch (e) {
meldung.value = e.message
return
}
faeden.value = faeden.value.filter(f => f.id !== id)
setze(`recherche:${props.projekt}:${id}`, '')
if (aktiv.value === id) aktiv.value = faeden.value[0]?.id || ''
}
watch(() => props.projekt, laden, { immediate: true })
watch(aktiv, nachUnten)
</script>
<template>
<aside class="recherche">
<header>
<p class="gruppe">Recherche</p>
<button class="icon-knopf" title="Neuen Faden öffnen" @click="neuerFaden">+</button>
</header>
<div v-if="faeden.length" class="reiter">
<button
v-for="f in faeden"
:key="f.id"
class="reiter-knopf"
:class="{ an: f.id === aktiv }"
:title="f.titel"
@click="aktiv = f.id"
>
<span v-if="f.id in seit" class="puls" />
<span class="name">{{ f.titel }}</span>
<span class="zu" title="Faden schließen" @click.stop="schliessen(f)">✕</span>
</button>
</div>
<p v-if="meldung" class="meldung">{{ meldung }}</p>
<div v-if="faden" ref="liste" class="verlauf">
<p v-if="!faden.verlauf.length" class="leer">
Frag nach, was du für die Planung wissen musst. Die Antwort kommt mit Quellen.
</p>
<div v-for="(e, i) in faden.verlauf" :key="i" class="zeile" :class="e.rolle">
<div v-if="e.rolle === 'ki'" class="inhalt" v-html="md(e.text)" />
<div v-else class="inhalt">{{ e.text }}</div>
</div>
<p v-if="laeuft" class="denkt">sucht … {{ sekunden }} s</p>
</div>
<p v-else class="leer">Kein Faden offen. Mit + einen aufmachen.</p>
<div v-if="faden" class="eingabe">
<!-- Tippen bleibt möglich, während die Antwort läuft — nur Senden wartet -->
<textarea
ref="feld"
v-model="entwurf"
rows="3"
placeholder="Was willst du wissen?"
@keydown.enter.exact.prevent="senden"
/>
<button class="primaer" :disabled="!entwurf.trim() || laeuft" @click="senden">
Suchen
</button>
</div>
<ModalBox
v-if="frage"
titel="Faden schließen"
:frage="`„${frage.titel}“ wird mit allem Gefundenen gelöscht. Schließen?`"
knopf="Schließen"
@bestaetigt="entferne(frage.id)"
@abbrechen="frage = null"
/>
</aside>
</template>
<style scoped>
.recherche {
background: var(--panel);
border-left: 1px solid var(--rand);
padding: 0.8rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
min-height: 0; /* sonst wächst das Panel über die Zeile hinaus */
}
header {
display: flex;
align-items: center;
gap: 0.3rem;
}
.gruppe {
flex: 1;
margin: 0;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--dezent);
}
/* Reiter wie im Browser: der aktive sitzt vorn, die anderen treten zurück.
Sie umbrechen, statt zu scrollen — auf 20rem passen sonst zwei. */
.reiter {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
border-bottom: 1px solid var(--rand);
padding-bottom: 0.4rem;
flex: none;
}
.reiter-knopf {
display: flex;
align-items: center;
gap: 0.3rem;
max-width: 100%;
border: 1px solid var(--rand);
border-radius: 0.4rem;
background: none;
color: var(--dezent);
padding: 0.15rem 0.4rem;
cursor: pointer;
font: inherit;
font-size: 0.74rem;
}
.reiter-knopf.an {
color: var(--akzent-text);
background: var(--akzent);
border-color: var(--akzent);
}
.reiter-knopf .name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reiter-knopf .zu { opacity: 0.55; flex: none; }
.reiter-knopf .zu:hover { opacity: 1; }
/* Eigener Scrollbereich: die Eingabe bleibt immer unten sichtbar */
.verlauf {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.leer { color: var(--dezent); font-size: 0.85rem; margin: 0; }
.zeile { display: flex; }
.zeile.person { justify-content: flex-end; }
.inhalt {
max-width: 92%;
padding: 0.4rem 0.6rem;
border-radius: 8px;
font-size: 0.84rem;
line-height: 1.45;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.person .inhalt { background: var(--akzent); color: var(--akzent-text); }
.ki .inhalt { background: var(--bg); white-space: normal; }
.ki .inhalt :deep(p) { margin: 0.3rem 0; }
.ki .inhalt :deep(p:first-child) { margin-top: 0; }
/* Die Antwort trägt ihre Struktur selbst: „Festgelegt", „Offen", „Quellen".
Klein und in Versalien, damit sie im schmalen Panel gliedert statt zu dröhnen. */
.ki .inhalt :deep(h2), .ki .inhalt :deep(h3) {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--dezent);
margin: 0.7rem 0 0.25rem;
}
.ki .inhalt :deep(ul), .ki .inhalt :deep(ol) {
margin: 0.25rem 0;
padding-left: 1rem;
}
.ki .inhalt :deep(li) { margin: 0.12rem 0; }
/* Quellen sind der Zweck der Recherche — Links müssen erkennbar und umbrechbar sein */
.ki .inhalt :deep(a) { color: var(--akzent); overflow-wrap: anywhere; }
.ki .inhalt :deep(code) { font-size: 0.92em; overflow-wrap: anywhere; }
/* Vergleichstabellen laufen im schmalen Panel sonst über den Rand */
.ki .inhalt :deep(table) {
display: block;
overflow-x: auto;
border-collapse: collapse;
font-size: 0.94em;
}
.ki .inhalt :deep(th), .ki .inhalt :deep(td) {
border: 1px solid var(--rand);
padding: 0.15rem 0.35rem;
text-align: left;
white-space: nowrap;
}
.denkt { color: var(--dezent); font-size: 0.82rem; margin: 0; }
.eingabe { display: flex; flex-direction: column; gap: 0.4rem; }
textarea { width: 100%; resize: vertical; font-size: 0.86rem; }
/* Ein laufender Faden blinkt im Reiter — auch wenn man woanders liest */
.puls {
display: inline-block;
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
background: currentColor;
animation: puls 1.2s ease-in-out infinite;
flex: none;
}
@keyframes puls {
0%, 100% { opacity: 0.25; }
50% { opacity: 1; }
}
</style>

View File

@@ -1,8 +1,10 @@
<script setup>
import { nextTick, ref } from 'vue'
import { projektAnlegen, projektUmbenennen, projektLoeschen } from '../api.js'
import { projektAnlegen, projektUmbenennen, projektLoeschen,
fetchVerzeichnis, verzeichnisSetzen } from '../api.js'
import { oeffneMenue } from '../composables/kontextmenue.js'
import ModalBox from './ModalBox.vue'
import OrdnerWahl from './OrdnerWahl.vue'
defineProps({
projekte: { type: Array, required: true },
@@ -14,7 +16,7 @@ const meldung = ref('')
const bearbeitet = ref(null) // Projektname, der gerade umbenannt wird
const umbenennung = ref('')
const umbenennFeld = ref(null)
const modal = ref(null) // {art: 'erstellen'|'entfernen', name?}
const modal = ref(null) // {art: 'erstellen'|'entfernen'|'verzeichnis', name?, pfad?}
async function starteUmbenennen(name) {
meldung.value = ''
@@ -40,10 +42,32 @@ async function umbenennen(alt) {
function projektMenue(e, name) {
oeffneMenue(e, [
{ text: '✎ Projekt anpassen', aktion: () => starteUmbenennen(name) },
{ text: '▦ Verzeichnis festlegen', aktion: () => starteVerzeichnis(name) },
{ text: '✕ Projekt entfernen', aktion: () => oeffneModal({ art: 'entfernen', name }) },
])
}
// Wo der Agent die Planung implementiert. Ohne Verzeichnis wird nur umgesetzt.
async function starteVerzeichnis(name) {
meldung.value = ''
let pfad = ''
try {
pfad = (await fetchVerzeichnis(name)).pfad
} catch (e) {
meldung.value = e.message
}
modal.value = { art: 'verzeichnis', name, pfad }
}
async function setzeVerzeichnis(pfad) {
try {
await verzeichnisSetzen(modal.value.name, pfad)
modal.value = null
} catch (e) {
meldung.value = e.message
}
}
function leistenMenue(e) {
oeffneMenue(e, [{ text: '+ Projekt erstellen', aktion: () => oeffneModal({ art: 'erstellen' }) }])
}
@@ -107,6 +131,13 @@ async function entfernen() {
@bestaetigt="erstellen"
@abbrechen="modal = null"
/>
<OrdnerWahl
v-else-if="modal && modal.art === 'verzeichnis'"
:vorgabe="modal.pfad"
:meldung="meldung"
@bestaetigt="setzeVerzeichnis"
@abbrechen="modal = null"
/>
<ModalBox
v-else-if="modal"
titel="Projekt entfernen"

View File

@@ -1,184 +0,0 @@
<script setup>
import { nextTick, ref, watch } from 'vue'
import { marked } from 'marked'
import { featureSpeichern } from '../api.js'
import { oeffneMenue } from '../composables/kontextmenue.js'
// Die Vision hält fest, wie es gedacht ist. Sie ist die Vorlage, nicht der
// Prüfling — deshalb hat sie weder Badge noch Funktionen noch Problemhinweise.
// Eine eigene Karte statt eines Sonderwegs in FeatureKarte: dort steckt die ganze
// Prüfmechanik, von der hier nichts gilt.
//
// Die beiden Modi hat sie wie jede Karte: Kompaktmodus zum Lesen, Vollmodus zum
// Anpassen. Kompakt bleiben nur die obersten Punkte stehen — eine Vision ist
// verschachtelt, und ausgeklappt drängt sie alles andere aus dem Bild.
const props = defineProps({
projekt: { type: String, required: true },
feature: { type: Object, required: true },
})
const emit = defineEmits(['aktualisiert', 'entfernen'])
const TITEL = 'Vision'
const TRENNER = '-'.repeat(40)
const text = ref(props.feature.sektionen.ziel)
watch(() => props.feature, f => { text.value = f.sektionen.ziel })
const gross = ref(false)
const aktiv = ref(false)
const entwurf = ref('')
const meldung = ref('')
const feldRef = ref(null)
// Lokale, selbst geschriebene Daten — kein Sanitizing nötig
const md = (t) => marked.parse(t)
async function starte() {
if (!gross.value) return // Bearbeiten nur in der ausführlichen Ansicht
entwurf.value = text.value
aktiv.value = true
await nextTick()
feldRef.value?.focus()
}
function abbrechen() {
aktiv.value = false
}
async function fertig() {
if (!aktiv.value) return
aktiv.value = false
const neu = entwurf.value.trim()
if (neu === text.value) return
text.value = neu
await speichern()
}
// Kompaktmodus zum Lesen, Vollmodus zum Anpassen — MMB auf die Karte
// oder das Augen-Icon schaltet um
function wechsleAnsicht() {
gross.value = !gross.value
if (!gross.value) aktiv.value = false
}
function kartenMenue(e) {
oeffneMenue(e, [{ text: '✕ Vision entfernen', aktion: () => emit('entfernen') }])
}
function baueRoh() {
return [
`<!-- titel -->\n${TITEL}`,
'<!-- art -->\nvision',
`<!-- ziel -->\n${text.value}`,
].join(`\n${TRENNER}\n`) + '\n'
}
async function speichern() {
meldung.value = ''
try {
emit('aktualisiert', props.feature.id,
await featureSpeichern(props.projekt, props.feature.id, baueRoh()))
} catch (e) {
meldung.value = e.message
}
}
function zeilen(t) {
return Math.max(6, t.split('\n').length + 1)
}
</script>
<template>
<article
class="karte vision"
:class="{ gross }"
@mousedown.middle.prevent
@auxclick.middle.prevent="wechsleAnsicht"
@contextmenu.prevent.stop="kartenMenue"
>
<header>
<h3>{{ TITEL }}</h3>
<button
class="icon-knopf"
:title="gross ? 'Kompaktmodus' : 'Vollmodus'"
@click="wechsleAnsicht"
>👁</button>
</header>
<textarea
v-if="aktiv"
ref="feldRef"
v-model="entwurf"
:rows="zeilen(entwurf)"
@blur="fertig"
@keydown.esc="abbrechen"
/>
<div
v-else-if="text"
class="inhalt"
:class="{ klickbar: gross }"
:title="gross ? 'Klicken zum Bearbeiten' : null"
v-html="md(text)"
@click="starte"
/>
<p v-else-if="gross" class="hinweis" @click="starte">Wie soll es werden?</p>
<p v-else class="hinweis">Noch nichts festgehalten.</p>
<p v-if="meldung" class="meldung">{{ meldung }}</p>
</article>
</template>
<style scoped>
/* Goldener Rand. Kompakt eine Karte wie jede andere, im Vollmodus über alle
Spalten — sonst würde der ausgeklappte Text die schmale Spalte sprengen. */
.vision { border: 2px solid var(--gold); cursor: pointer; }
.vision.gross { cursor: default; column-span: all; display: block; }
header {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.5rem;
}
h3 {
margin: 0;
flex: 1;
font-size: 0.95rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--gold);
user-select: none;
}
.klickbar { cursor: text; border-radius: 4px; }
.klickbar:hover { background: var(--gold-bg); }
.hinweis {
margin: 0;
color: var(--dezent);
user-select: none;
}
.gross .hinweis { cursor: text; }
textarea {
width: 100%;
resize: vertical;
border-color: var(--gold);
}
.inhalt :deep(p) { margin: 0.2rem 0; }
.inhalt :deep(ul), .inhalt :deep(ol) { margin: 0.2rem 0; padding-left: 1.2rem; }
.inhalt :deep(li) { margin: 0.1rem 0; }
.inhalt :deep(h1), .inhalt :deep(h2), .inhalt :deep(h3) {
font-size: 0.95rem;
margin: 0.6rem 0 0.3rem;
}
/* Kompakt: nur die oberste Ebene. Die Verschachtelung ist die Ausarbeitung —
sie gehört in den Vollmodus, sonst füllt die Vision den halben Schirm. */
.vision:not(.gross) .inhalt {
user-select: none;
max-height: 11rem;
overflow: hidden;
}
.vision:not(.gross) .inhalt :deep(ul ul),
.vision:not(.gross) .inhalt :deep(ul ol),
.vision:not(.gross) .inhalt :deep(ol ul),
.vision:not(.gross) .inhalt :deep(ol ol) { display: none; }
.meldung { margin: 0.4rem 0 0; }
</style>

View File

@@ -0,0 +1,24 @@
import { reactive } from 'vue'
// Halb getippte Nachrichten überleben den Reiterwechsel.
//
// Panels werden beim Wechsel abgeräumt und neu gebaut; ein `ref` in der Komponente
// stirbt mit ihr. Wer mitten im Satz nachschauen wollte, was auf einer Karte steht,
// fand danach ein leeres Feld vor. Also liegt der Text außerhalb der Komponente.
//
// Der Schlüssel nennt Ort und Gegenstand — `chat:MeinProjekt`,
// `recherche:MeinProjekt:a1b2c3d4`. Zwei Fäden teilen sich so keinen Entwurf, und
// zwei Projekte auch nicht.
//
// Nur im Speicher: ein Entwurf gilt für diese Sitzung. Was bleiben soll, wird
// abgeschickt.
const texte = reactive({})
export function lies(schluessel) {
return texte[schluessel] || ''
}
export function setze(schluessel, text) {
if (text) texte[schluessel] = text
else delete texte[schluessel]
}

View File

@@ -1,7 +1,8 @@
import { computed, ref } from 'vue'
import { starteUhr, stoppeUhr } from './uhr.js'
import {
fetchPlanung, planungStarten, planungUmsetzen, planungAbbrechen,
planungZuruecksetzen,
planungZuruecksetzen, fetchAuftrag,
} from '../api.js'
// Zwei Sichten auf dieselben Karten. „Übersicht" sagt, was das Projekt kann.
@@ -36,13 +37,8 @@ function uebernehmen(stand) {
else if (!vorher) sicht.value = 'planung'
}
export async function lade(projekt) {
projektName = projekt
meldung.value = ''
if (!projekt) {
uebernehmen({})
return
}
// Nur den Planungsstand nachlesen — der Agentenzustand bleibt unangetastet.
async function ladeStand(projekt) {
try {
uebernehmen(await fetchPlanung(projekt))
} catch (e) {
@@ -50,6 +46,27 @@ export async function lade(projekt) {
}
}
export async function lade(projekt) {
projektName = projekt
meldung.value = ''
// Agentenzustand gehört zum Projekt — beim Wechsel neu erfragen
stoppeUhr(UHR)
agentLaeuft.value = false
agentMeldung.value = ''
agentBericht.value = ''
agentSchritte.value = []
agentAktivitaet.value = ''
agentUebernommen.value = false
if (!projekt) {
uebernehmen({})
return
}
await ladeStand(projekt)
try {
if ((await fetchAuftrag(projekt)).laeuft) beobachteAgent()
} catch { /* ohne Antwort läuft eben keiner */ }
}
export async function starte() {
if (!projektName) return false
meldung.value = ''
@@ -69,7 +86,8 @@ export async function umsetzen() {
if (!projektName) return false
meldung.value = ''
try {
await planungUmsetzen(projektName)
const antwort = await planungUmsetzen(projektName)
if (antwort.agent) beobachteAgent()
} catch (e) {
meldung.value = e.message
return false
@@ -78,6 +96,50 @@ export async function umsetzen() {
return true
}
// Mit Arbeitsverzeichnis implementiert Claude die umgesetzte Planung im Code.
// Der Lauf gehört zum Projekt; die Anzeige fragt nach, bis er fertig ist.
export const agentLaeuft = ref(false)
export const agentMeldung = ref('')
export const agentBericht = ref('')
export const agentSchritte = ref([])
export const agentAktivitaet = ref('')
export const agentUebernommen = ref(false)
const UHR = '__planer2AgentUhr'
async function pruefeAgent() {
if (!projektName) return
let stand
try {
stand = await fetchAuftrag(projektName)
} catch {
return // kurzer Aussetzer — die nächste Runde fragt wieder
}
agentLaeuft.value = stand.laeuft
agentSchritte.value = stand.schritte || []
agentAktivitaet.value = stand.aktivitaet || ''
if (stand.laeuft) return
stoppeUhr(UHR)
if (stand.fehler) agentMeldung.value = `Agent: ${stand.fehler}`
else if (stand.uebernommen) {
agentMeldung.value = 'Claude hat die Planung implementiert — sie ist jetzt der Projektstand.'
agentBericht.value = stand.text
agentUebernommen.value = true
await ladeStand(projektName) // die Planung ist weg, der Schalter fällt zurück
}
}
function beobachteAgent() {
agentLaeuft.value = true
agentMeldung.value = ''
agentBericht.value = ''
agentSchritte.value = []
agentAktivitaet.value = ''
agentUebernommen.value = false
starteUhr(UHR, pruefeAgent, 2000)
pruefeAgent()
}
export async function brichAb() {
if (!projektName) return false
meldung.value = ''

View File

@@ -1,4 +1,5 @@
import { computed, ref } from 'vue'
import { starteUhr, stoppeUhr } from './uhr.js'
import { pruefungStarten, fetchPruefung, pruefungBeenden } from '../api.js'
// Ein Prüflauf für das aktive Projekt; Zustand global, damit Sidebar,
@@ -13,7 +14,7 @@ export const probleme = ref([])
const gesehen = new Set() // schon übernommene Probleme holt der nächste Abruf nicht erneut
let projektName = null
let uhr = null
const UHR = '__planer2PruefUhr'
export const prozent = computed(() =>
gesamt.value ? Math.round((fertig.value / gesamt.value) * 100) : 0)
@@ -98,10 +99,20 @@ export function ergaenze(neue) {
const k = schluessel(p)
if (gesehen.has(k)) continue
gesehen.add(k)
// Markiert, woher der Vorschlag kommt: Gesprächsvorschläge überleben es,
// wenn eine Prüfung startet oder ihr Panel zugeht — sie gehören dem Gespräch.
p.gespraech = true
probleme.value.push(p)
}
}
// Nur die Prüfergebnisse wegräumen; was das Gespräch vorgeschlagen hat, bleibt
function nurGespraech() {
probleme.value = probleme.value.filter(p => p.gespraech)
gesehen.clear()
probleme.value.forEach(p => gesehen.add(schluessel(p)))
}
// Ein angenommener Titelvorschlag benennt Karte oder Funktion um. Die übrigen Probleme
// zeigen dann noch auf den alten Namen und wären in der Karte nicht mehr auffindbar.
export function benenneUm(featureId, feld, alt, neu) {
@@ -117,12 +128,7 @@ async function abfragen() {
meldung.value = e.message
laeuft.value = false
}
if (!laeuft.value) stoppeUhr()
}
function stoppeUhr() {
clearInterval(uhr)
uhr = null
if (!laeuft.value) stoppeUhr(UHR)
}
export async function starte(projekt, pruefart = 'stil') {
@@ -130,8 +136,7 @@ export async function starte(projekt, pruefart = 'stil') {
art.value = pruefart
offen.value = true
meldung.value = ''
gesehen.clear()
probleme.value = []
nurGespraech()
try {
uebernehmen(await pruefungStarten(projekt, pruefart))
} catch (e) {
@@ -148,8 +153,8 @@ export async function starte(projekt, pruefart = 'stil') {
return
}
}
stoppeUhr()
uhr = setInterval(abfragen, 1000)
stoppeUhr(UHR)
starteUhr(UHR, abfragen, 1000)
}
// Angenommenes oder abgelehntes Problem verschwindet, die übrigen bleiben stehen.
@@ -165,9 +170,20 @@ export function verwirf(problem) {
}
export async function schliesse() {
stoppeUhr()
stoppeUhr(UHR)
offen.value = false
probleme.value = []
laeuft.value = false
nurGespraech()
if (projektName) await pruefungBeenden(projektName).catch(() => {})
}
// Projektwechsel: hier gehört alles weg, auch die Gesprächsvorschläge — sie
// zeigten sonst auf gleichnamige Karten des nächsten Projekts.
export async function verwerfeAlles() {
stoppeUhr(UHR)
offen.value = false
laeuft.value = false
probleme.value = []
gesehen.clear()
if (projektName) await pruefungBeenden(projektName).catch(() => {})
}

View File

@@ -0,0 +1,19 @@
// Wiederkehrende Abfragen — ein Intervall je Name.
//
// Der Griff liegt am globalen Objekt, nicht im Modul. Vite tauscht in der Entwicklung
// bei jeder Änderung das Modul aus und führt es neu aus; das laufende Intervall räumt
// es dabei nicht ab. Die Modulvariable wäre danach leer, der alte Timer unerreichbar —
// und er fragt weiter. Nach einer Handvoll Änderungen pollten fünf Uhren parallel.
//
// `import.meta.hot.dispose` reicht dafür nicht: Vite ruft es nur bei Modulen, die den
// Austausch selbst annehmen. Ein Name am globalen Objekt überlebt jeden Tausch.
export function starteUhr(name, aufgabe, ms) {
stoppeUhr(name)
globalThis[name] = setInterval(aufgabe, ms)
}
export function stoppeUhr(name) {
clearInterval(globalThis[name])
globalThis[name] = null
}