feat(lessons): a lesson is readable, writable and browsable by a human (#3734)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Failing after 31s
CI & Build / integration (push) Successful in 48s
CI & Build / Python tests (push) Successful in 1m33s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Failing after 31s
CI & Build / integration (push) Successful in 48s
CI & Build / Python tests (push) Successful in 1m33s
CI & Build / Build & push image (push) Skipped
Step 7's actual UI. Before this the frontend had zero lesson code — the kind existed for agents only, which is rule 27 failing. THE EDITOR ASKS FOR THE TRIGGER BY NAME, and leads with it. Three fields — the trigger, the claim, the detail — never one markdown box. That is the design step 1 settled, and the evidence is blunt: the snippet corpus carries a trigger on every record with no guard anywhere, because a service composes the title from a named parameter. What is at 100% is a named structured field, not a writer remembering a convention. The trigger gets the most room, its own explanation, and a save button that refuses without it and says why. The form shows the composed title live, so the writer is agreeing to a document they can read rather than one assembled out of sight. A 409 from the duplicate gate is rendered as the record that already covers the moment, with a link to improve it and an explicit override — not as a failure. THE BROWSE VOCABULARY GAINS THE KIND, which #3161 warned this step not to get wrong: a facet chip, a badge label, and routing to `/lessons/:id` rather than the note editor, which cannot edit a trigger. The badge is neutral alongside snippet and process — a hue would make the softest record in the corpus look like the loudest, next to a rule that actually binds. BOTH DIRECTIONS OF THE PROVENANCE. The detail page resolves `learned_from` to titles rather than bare ids, because "#4181" tells a reader nothing about whether it is worth opening. And `LessonsTaughtPanel` answers the reverse on the record's own page — the direction the task body calls the one that gets forgotten. It has no author to type it, which is exactly why it tends never to get built. A component, not markup in the task editor, so the same panel mounts on any record a lesson can cite instead of being written a second time (#3207). Silent when empty: most records taught no lesson, and a panel that says "None yet" everywhere is one people learn to skip. GLOBAL-BY-DEFAULT IS MADE LEGIBLE. A lesson meeting you on a project it was not written on reads as a bug unless the page says otherwise, so the origin line says it as a property of the kind rather than as an apology. Design system tokens throughout; no new raw hex. `--fs-error` rather than `--fs-danger` — 31 uses against 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Write or edit a LESSON — a transferable insight, found by the situation it
|
||||
* applies to rather than by its topic.
|
||||
*
|
||||
* THREE FIELDS, NOT ONE MARKDOWN BOX. The title and body are composed by the
|
||||
* service from `what`, `when_to_apply` and `insight`; this form never sends a
|
||||
* document. That is the design milestone 385 step 1 settled on, and the
|
||||
* evidence for it is blunt: the snippet corpus carries a trigger on every
|
||||
* record with no guard anywhere, because a service composes the title from a
|
||||
* named parameter. What is at 100% is a named structured field — not a writer
|
||||
* remembering a convention.
|
||||
*
|
||||
* THE TRIGGER IS THE FIELD THAT CANNOT BE MISSED, so it is given the most
|
||||
* room, its own explanation, and a save button that refuses without it. A
|
||||
* lesson with no trigger saves, reads correctly in every listing, and never
|
||||
* surfaces — and there is nothing to notice afterwards, because it looks
|
||||
* exactly like a lesson that works. The form is where that gets caught.
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import {
|
||||
createLesson,
|
||||
getLesson,
|
||||
updateLesson,
|
||||
type Lesson,
|
||||
} from "@/api/lessons";
|
||||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||
import TagInput from "@/components/TagInput.vue";
|
||||
import { useNotesStore } from "@/stores/notes";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const toast = useToastStore();
|
||||
const notesStore = useNotesStore();
|
||||
|
||||
const lessonId = computed(() => {
|
||||
const raw = route.params.id;
|
||||
return raw ? Number(raw) : null;
|
||||
});
|
||||
const isEdit = computed(() => lessonId.value !== null);
|
||||
|
||||
const what = ref("");
|
||||
const whenToApply = ref("");
|
||||
const insight = ref("");
|
||||
const tags = ref<string[]>([]);
|
||||
const projectId = ref<number | null>(null);
|
||||
const learnedFrom = ref<number[]>([]);
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
// The near-duplicate gate's answer, held so the writer can read it and then
|
||||
// decide — rather than being silently overridden or silently blocked.
|
||||
const duplicate = ref<{ id: number; title: string } | null>(null);
|
||||
|
||||
/** The composed title, shown live. The writer is agreeing to a document they
|
||||
* can see, which is the same reason `create_rule` shows a rule's statement
|
||||
* before asking for a yes. */
|
||||
const previewTitle = computed(() => {
|
||||
const subject = what.value.trim();
|
||||
const trigger = whenToApply.value.trim();
|
||||
if (subject && trigger) return `${subject} — ${trigger}`;
|
||||
return subject || trigger;
|
||||
});
|
||||
|
||||
const canSave = computed(
|
||||
() => what.value.trim().length > 0 && whenToApply.value.trim().length > 0,
|
||||
);
|
||||
|
||||
async function load() {
|
||||
if (!isEdit.value || lessonId.value === null) return;
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const lesson: Lesson = await getLesson(lessonId.value);
|
||||
what.value = lesson.what;
|
||||
whenToApply.value = lesson.when_to_apply;
|
||||
// The insight WITHOUT the composed lines, so saving doesn't accumulate a
|
||||
// copy of the trigger line on every edit.
|
||||
insight.value = lesson.insight;
|
||||
tags.value = lesson.tags ?? [];
|
||||
projectId.value = lesson.project_id;
|
||||
learnedFrom.value = lesson.learned_from ?? [];
|
||||
} catch (e) {
|
||||
error.value = apiErrorMessage(e, "Failed to load this lesson");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(force = false) {
|
||||
if (!canSave.value || saving.value) return;
|
||||
saving.value = true;
|
||||
error.value = null;
|
||||
duplicate.value = null;
|
||||
try {
|
||||
const payload = {
|
||||
what: what.value.trim(),
|
||||
when_to_apply: whenToApply.value.trim(),
|
||||
insight: insight.value,
|
||||
tags: tags.value,
|
||||
learned_from: learnedFrom.value,
|
||||
project_id: projectId.value,
|
||||
...(force ? { force: true } : {}),
|
||||
};
|
||||
const saved = isEdit.value && lessonId.value !== null
|
||||
? await updateLesson(lessonId.value, payload)
|
||||
: await createLesson(payload);
|
||||
toast.show(isEdit.value ? "Lesson updated" : "Lesson recorded");
|
||||
router.push(`/lessons/${saved.id}`);
|
||||
} catch (e) {
|
||||
// A 409 is the duplicate gate, not a failure: it hands back the record
|
||||
// that already covers this moment so the writer can improve that one
|
||||
// instead of standing a second beside it.
|
||||
const body = (e as { status?: number; body?: Record<string, unknown> });
|
||||
if (body?.status === 409 && body.body) {
|
||||
const existing = body.body as { id?: number; title?: string };
|
||||
if (existing.id) {
|
||||
duplicate.value = { id: existing.id, title: existing.title ?? "" };
|
||||
return;
|
||||
}
|
||||
}
|
||||
error.value = apiErrorMessage(e, "Failed to save this lesson");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(lessonId, load);
|
||||
onMounted(() => {
|
||||
load();
|
||||
// Pre-fill from the link that brought you here, the same convention the
|
||||
// snippet editor uses. Left null it is a lesson with no recorded origin,
|
||||
// which is valid — `project_id` says where a lesson was LEARNED and was
|
||||
// never the limit on where it can be found (step 3).
|
||||
if (!isEdit.value && route.query.projectId) {
|
||||
projectId.value = Number(route.query.projectId);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lesson-editor">
|
||||
<header class="le-head">
|
||||
<h1>{{ isEdit ? "Edit lesson" : "Record a lesson" }}</h1>
|
||||
<p class="le-sub">
|
||||
Something worth knowing, kept so a later session meets it at the moment
|
||||
it applies. A lesson binds nobody.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<p v-if="error" class="le-error" role="alert">{{ error }}</p>
|
||||
|
||||
<div v-if="duplicate" class="le-dupe" role="alert">
|
||||
<p>
|
||||
<strong>A lesson already covers this moment.</strong>
|
||||
Improving that one keeps what was learned in a single place — two
|
||||
lessons under one trigger compete for the same slot, so the second
|
||||
displaces the first rather than adding to it.
|
||||
</p>
|
||||
<div class="le-dupe-actions">
|
||||
<router-link :to="`/lessons/${duplicate.id}`" class="le-link">
|
||||
Open “{{ duplicate.title }}”
|
||||
</router-link>
|
||||
<button type="button" class="le-ghost" @click="save(true)">
|
||||
Record it anyway
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="le-muted">Loading…</p>
|
||||
|
||||
<form v-else class="le-form" @submit.prevent="save()">
|
||||
<!-- The trigger comes FIRST, before the claim. It is what makes a lesson
|
||||
findable, and putting it second invites it to be treated as an
|
||||
afterthought to the thing the writer arrived wanting to say. -->
|
||||
<label class="le-field le-field--primary">
|
||||
<span class="le-label">When does this apply?</span>
|
||||
<span class="le-hint">
|
||||
The situation, in the words it will present itself in — what someone
|
||||
would be seeing, saying, or about to do. “A test fails on code you
|
||||
believe is correct” is a trigger. “Testing” is a topic, and a topic
|
||||
matches everything and surfaces for nothing.
|
||||
</span>
|
||||
<textarea
|
||||
v-model="whenToApply"
|
||||
class="le-input le-textarea"
|
||||
rows="3"
|
||||
required
|
||||
placeholder="a CI run has sat in_progress far longer than its suite takes"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="le-field">
|
||||
<span class="le-label">What did you learn?</span>
|
||||
<span class="le-hint">The claim itself, in one line, as you would say it.</span>
|
||||
<input
|
||||
v-model="what"
|
||||
class="le-input"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Read the job log before waiting longer"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="le-field">
|
||||
<span class="le-label">The detail <em>(optional)</em></span>
|
||||
<span class="le-hint">
|
||||
What you would want handed to you in the same situation next time —
|
||||
the evidence, the reasoning, the thing that is not obvious.
|
||||
</span>
|
||||
<textarea
|
||||
v-model="insight"
|
||||
class="le-input le-textarea le-textarea--tall"
|
||||
rows="10"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="le-field">
|
||||
<span class="le-label">Tags</span>
|
||||
<TagInput
|
||||
v-model="tags"
|
||||
:fetchTags="(q: string) => notesStore.fetchAllTags(q)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="le-field">
|
||||
<span class="le-label">Learned on <em>(optional)</em></span>
|
||||
<span class="le-hint">
|
||||
Where this was learned. Kept as a fact about its origin — a lesson is
|
||||
retrievable from every project regardless, which is the point of the
|
||||
kind.
|
||||
</span>
|
||||
<ProjectSelector v-model="projectId" />
|
||||
</label>
|
||||
|
||||
<!-- The composed document, shown before saving. The writer is agreeing
|
||||
to a title they can read, not to one assembled out of sight. -->
|
||||
<div v-if="previewTitle" class="le-preview">
|
||||
<span class="le-preview-label">Stored as</span>
|
||||
<p class="le-preview-title">{{ previewTitle }}</p>
|
||||
</div>
|
||||
|
||||
<div class="le-actions">
|
||||
<button type="submit" class="le-primary" :disabled="!canSave || saving">
|
||||
{{ saving ? "Saving…" : isEdit ? "Save lesson" : "Record lesson" }}
|
||||
</button>
|
||||
<button type="button" class="le-ghost" @click="router.back()">
|
||||
Cancel
|
||||
</button>
|
||||
<!-- Says WHY it is disabled. A greyed button with no reason is the
|
||||
thing that gets clicked repeatedly and then worked around. -->
|
||||
<span v-if="!canSave" class="le-muted le-why">
|
||||
A lesson needs both a trigger and a claim — without the trigger it
|
||||
would save and never reach anyone.
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lesson-editor {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
padding: var(--fs-layout-page-pad);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
|
||||
.le-head h1 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.le-sub {
|
||||
margin: 0 0 1.5rem;
|
||||
color: var(--fs-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.le-form { display: flex; flex-direction: column; gap: 1.25rem; }
|
||||
|
||||
.le-field { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
|
||||
/* The trigger gets visible weight, because it is the field whose absence is
|
||||
invisible afterwards. */
|
||||
.le-field--primary {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
|
||||
.le-label { font-weight: 500; font-size: 0.9rem; }
|
||||
.le-label em { font-style: normal; color: var(--fs-text-tertiary); font-weight: 400; }
|
||||
.le-hint {
|
||||
color: var(--fs-text-secondary);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.le-input {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font: inherit;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.le-input:focus-visible {
|
||||
outline: 2px solid var(--fs-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.le-textarea { resize: vertical; line-height: 1.5; }
|
||||
.le-textarea--tall { font-family: var(--fs-font-mono); font-size: 0.85rem; }
|
||||
|
||||
.le-preview {
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-left: 2px solid var(--fs-accent);
|
||||
background: var(--fs-surface-raised);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
}
|
||||
.le-preview-label {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.le-preview-title { margin: 0.2rem 0 0; font-size: 0.92rem; }
|
||||
|
||||
.le-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; }
|
||||
|
||||
.le-primary {
|
||||
padding: 0.5rem 1.1rem;
|
||||
border: none;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.le-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.le-ghost {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--fs-text-primary);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.le-muted { color: var(--fs-text-secondary); font-size: 0.85rem; }
|
||||
.le-why { flex-basis: 100%; }
|
||||
|
||||
.le-error {
|
||||
padding: 0.6rem 0.8rem;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: color-mix(in srgb, var(--fs-error) 12%, var(--fs-surface-raised));
|
||||
color: color-mix(in srgb, var(--fs-error) 55%, var(--fs-text-primary));
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.le-dupe {
|
||||
padding: 0.8rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
background: var(--fs-surface-raised);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.le-dupe p { margin: 0 0 0.6rem; line-height: 1.5; }
|
||||
.le-dupe-actions { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center; }
|
||||
.le-link { color: var(--fs-accent); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user