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

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:
2026-09-19 14:11:39 -04:00
co-authored by Claude Opus 5
parent d36d68a20f
commit 95dc25eaab
9 changed files with 1011 additions and 4 deletions
+146
View File
@@ -0,0 +1,146 @@
import type { RecordUsage } from "@/types/usage";
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
/** A lesson: a transferable insight, retrievable by the SITUATION it applies
* to rather than by its topic.
*
* The fields mirror what the backend composes and reads back
* (`services/lessons.py::lesson_to_dict`), not the stored row. `title` and
* `body` are DERIVED — the service builds them from `what`, `when_to_apply`
* and `insight` — so an editor sends the three parts and never the document.
* That is the whole design: the trigger ends up in the title and again at the
* head of the body, which is what makes a lesson rank on when it applies. */
export interface Lesson {
id: number;
/** The composed document title, `{what} — {when_to_apply}`. Read-only. */
title: string;
/** The composed body. Read-only — edit `insight` instead. */
body: string;
/** The claim itself, as you would say it. */
what: string;
/** WHEN this applies — the situation, in the words it presents itself in.
* The entire retrieval story: a lesson without one saves, reads correctly
* and never surfaces, so both doors refuse an empty one. */
when_to_apply: string;
/** The body with the composed lines stripped — what an edit form binds to,
* so saving doesn't accumulate a copy of the trigger line per save. */
insight: string;
/** Ids of the records that taught this — issues, tasks or notes. */
learned_from: number[];
/** The same sources RESOLVED, sent by the detail route only. A bare "#4181"
* on a page tells a reader nothing about whether it is worth opening, and
* the provenance is the point of a lesson — one that loses its incidents
* loses its evidence. A source that has been deleted drops out rather than
* rendering a link to nothing. */
learned_from_records?: {
id: number;
title: string;
note_type: string;
is_task: boolean;
task_kind: string | null;
status: string | null;
}[];
tags: string[];
note_type: string;
/** Where it was LEARNED. Kept as a fact, but not a limit on where it can be
* found: a lesson is retrievable from every project (milestone 385 step 3). */
project_id: number | null;
permission?: string;
created_at: string | null;
updated_at: string | null;
systems?: { id: number; name: string }[];
usage?: RecordUsage;
/** Set when another user owns this record. */
shared?: boolean;
owner?: string | null;
}
/** A row in the browse listing — the trigger travels with it, because a list
* of lessons without their triggers is a list of claims with the half that
* says when each one matters left off. */
export interface LessonListRow {
id: number;
title: string;
tags: string[];
when_to_apply?: string;
snippet?: string;
shared?: boolean;
owner?: string | null;
}
export interface LessonListResponse {
lessons: LessonListRow[];
total: number;
}
/** What the create/update forms send. `what` and `when_to_apply` are required
* on create; every field is optional on update, and the service re-composes
* the whole document from the merged set — so a partial save can never leave
* the title and body disagreeing about the trigger. */
export interface LessonPayload {
what?: string;
when_to_apply?: string;
insight?: string;
learned_from?: number[];
tags?: string[];
project_id?: number | null;
system_ids?: number[];
/** Deliberate override of the near-duplicate gate, once the writer has seen
* the warning. Two lessons under one trigger compete for one reserved slot,
* so a duplicate displaces rather than merely clutters. */
force?: boolean;
}
export function listLessons(params: {
q?: string;
tag?: string;
project_id?: number;
limit?: number;
offset?: number;
} = {}): Promise<LessonListResponse> {
const qs = new URLSearchParams();
if (params.q) qs.set("q", params.q);
if (params.tag) qs.set("tag", params.tag);
if (params.project_id) qs.set("project_id", String(params.project_id));
if (params.limit != null) qs.set("limit", String(params.limit));
if (params.offset != null) qs.set("offset", String(params.offset));
const suffix = qs.toString() ? `?${qs}` : "";
return apiGet<LessonListResponse>(`/api/lessons${suffix}`);
}
export function getLesson(id: number): Promise<Lesson> {
return apiGet<Lesson>(`/api/lessons/${id}`);
}
export function createLesson(payload: LessonPayload): Promise<Lesson> {
return apiPost<Lesson>("/api/lessons", payload);
}
export function updateLesson(
id: number,
payload: LessonPayload,
): Promise<Lesson> {
return apiPatch<Lesson>(`/api/lessons/${id}`, payload);
}
export function deleteLesson(
id: number,
): Promise<{ deleted: number; deleted_batch_id: string }> {
return apiDelete<{ deleted: number; deleted_batch_id: string }>(
`/api/lessons/${id}`,
);
}
/** The lessons drawn FROM one record — the reverse of `learned_from`.
*
* The direction that gets forgotten, and arguably the more useful one: a
* reader opening an old issue wants to know what was learned from it, and
* without this the relation is only navigable from the lesson's side. */
export function lessonsTaughtBy(
recordId: number,
): Promise<{ lessons: Lesson[]; taught_by: number }> {
return apiGet<{ lessons: Lesson[]; taught_by: number }>(
`/api/lessons/taught-by/${recordId}`,
);
}
@@ -0,0 +1,93 @@
<script setup lang="ts">
/**
* "What was learned from this record" — the reverse of a lesson's
* `learned_from`.
*
* THE DIRECTION THAT GETS FORGOTTEN, and arguably the more useful one. The
* forward link is easy to remember because the lesson's author types it; this
* one has no author and so tends never to get built. A reader opening an old
* issue wants to know what came out of it, and without this the relation is
* navigable only from the lesson's side.
*
* A COMPONENT rather than markup inside the task editor, because the same
* question is worth answering on any record a lesson can cite — an issue, a
* spike, a dev-log. One panel, mounted wherever that is true, instead of the
* shape being written a second time the first time someone wants it on notes
* (#3207).
*
* SILENT WHEN EMPTY. Most records taught no lesson, and a panel that renders
* "None yet" on every page is a panel people learn to skip — which costs the
* pages where it does have something to say.
*/
import { onMounted, ref, watch } from "vue";
import { lessonsTaughtBy, type Lesson } from "@/api/lessons";
const props = defineProps<{ recordId: number }>();
const lessons = ref<Lesson[]>([]);
// No error surface on purpose: this is a secondary panel beside the record the
// reader actually came for, and a red box about a failed side-query would be
// louder than the thing it failed to fetch. It stays silent and stays absent.
const loaded = ref(false);
async function load() {
loaded.value = false;
lessons.value = [];
if (!props.recordId) return;
try {
const res = await lessonsTaughtBy(props.recordId);
lessons.value = res.lessons;
} catch {
lessons.value = [];
} finally {
loaded.value = true;
}
}
watch(() => props.recordId, load);
onMounted(load);
</script>
<template>
<section v-if="loaded && lessons.length" class="ltp">
<h3 class="ltp-label">What was learned from this</h3>
<ul class="ltp-list">
<li v-for="l in lessons" :key="l.id" class="ltp-item">
<router-link :to="`/lessons/${l.id}`" class="ltp-link">
{{ l.what || l.title }}
</router-link>
<!-- The trigger travels with the row. A lesson listed without it is a
claim with the half that says when it matters left off. -->
<p v-if="l.when_to_apply" class="ltp-trigger">
{{ l.when_to_apply }}
</p>
</li>
</ul>
</section>
</template>
<style scoped>
.ltp {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--fs-border-color);
}
.ltp-label {
margin: 0 0 0.6rem;
font-size: 0.72rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--fs-text-tertiary);
}
.ltp-list { list-style: none; margin: 0; padding: 0; }
.ltp-item { margin-bottom: 0.7rem; }
.ltp-link { color: var(--fs-accent); font-size: 0.9rem; }
.ltp-trigger {
margin: 0.15rem 0 0;
color: var(--fs-text-secondary);
font-size: 0.82rem;
line-height: 1.45;
}
</style>
+19
View File
@@ -69,6 +69,25 @@ const router = createRouter({
name: "note-edit", name: "note-edit",
component: () => import("@/views/NoteEditorView.vue"), component: () => import("@/views/NoteEditorView.vue"),
}, },
{
// Lessons have no list view of their own: the Knowledge browse surface
// is where every kind is enumerated, and a second list would be a
// second vocabulary to keep in step with it (#3128's defect, in
// advance). `/knowledge?type=lesson` is the list.
path: "/lessons/new",
name: "lesson-new",
component: () => import("@/views/LessonEditorView.vue"),
},
{
path: "/lessons/:id",
name: "lesson-view",
component: () => import("@/views/LessonDetailView.vue"),
},
{
path: "/lessons/:id/edit",
name: "lesson-edit",
component: () => import("@/views/LessonEditorView.vue"),
},
{ {
path: "/snippets", path: "/snippets",
name: "snippets", name: "snippets",
+23 -4
View File
@@ -12,6 +12,7 @@ import {
FileText, FileText,
CheckSquare, CheckSquare,
Workflow, Workflow,
Lightbulb,
Search, Search,
Share2, Share2,
ShieldCheck, ShieldCheck,
@@ -26,7 +27,7 @@ const router = useRouter();
interface KnowledgeItem { interface KnowledgeItem {
id: number; id: number;
note_type: "note" | "task" | "process" | "snippet"; note_type: "note" | "task" | "process" | "snippet" | "lesson";
title: string; title: string;
snippet: string; snippet: string;
tags: string[]; tags: string[];
@@ -46,7 +47,8 @@ interface KnowledgeItem {
// ─── The facet vocabulary ───────────────────────────────────────────────────── // ─── The facet vocabulary ─────────────────────────────────────────────────────
// Mirrors services/knowledge._FACETS, which is where it is defined for real. // Mirrors services/knowledge._FACETS, which is where it is defined for real.
// A facet spans BOTH typing axes — a record TYPE (note / process / snippet) or // A facet spans BOTH typing axes — a record TYPE (note / process / snippet /
// lesson) or
// a task KIND (`task` for any, else issue / spike) — because that is what this // a task KIND (`task` for any, else issue / spike) — because that is what this
// feed actually holds. // feed actually holds.
// //
@@ -54,7 +56,7 @@ interface KnowledgeItem {
// it has no chip: retired in 0066, it kept a chip of its own for longer than // it has no chip: retired in 0066, it kept a chip of its own for longer than
// `issue` — 17% of every task here — went without one (#3128). Those rows are // `issue` — 17% of every task here — went without one (#3128). Those rows are
// still reachable under Tasks, wearing a Plan badge. // still reachable under Tasks, wearing a Plan badge.
type Facet = "" | "note" | "task" | "issue" | "spike" | "snippet" | "process"; type Facet = "" | "note" | "task" | "issue" | "spike" | "snippet" | "process" | "lesson";
// The facets that select TASKS. Kinds are subsets of `task`, so any of them // The facets that select TASKS. Kinds are subsets of `task`, so any of them
// means the duplicate report should be comparing tasks. // means the duplicate report should be comparing tasks.
@@ -67,6 +69,7 @@ const FACET_CHIPS: [Exclude<Facet, "">, string][] = [
["spike", "Spikes"], ["spike", "Spikes"],
["snippet", "Snippets"], ["snippet", "Snippets"],
["process", "Processes"], ["process", "Processes"],
["lesson", "Lessons"],
]; ];
// ─── View mode ──────────────────────────────────────────────────────────────── // ─── View mode ────────────────────────────────────────────────────────────────
@@ -153,6 +156,11 @@ function createNew(type: string) {
newNoteMenuOpen.value = false; newNoteMenuOpen.value = false;
if (type === "task") { if (type === "task") {
router.push("/tasks/new"); router.push("/tasks/new");
} else if (type === "lesson") {
// Its own editor, not /notes/new?type=lesson: the note editor offers one
// markdown box, and a lesson written that way saves without a trigger and
// never surfaces. The form has to ASK for the field by name.
router.push("/lessons/new");
} else { } else {
router.push(type === "note" ? "/notes/new" : `/notes/new?type=${type}`); router.push(type === "note" ? "/notes/new" : `/notes/new?type=${type}`);
} }
@@ -328,6 +336,8 @@ function openItem(item: KnowledgeItem) {
router.push(`/tasks/${item.id}`); router.push(`/tasks/${item.id}`);
} else if (item.note_type === 'snippet') { } else if (item.note_type === 'snippet') {
router.push(`/snippets/${item.id}`); router.push(`/snippets/${item.id}`);
} else if (item.note_type === 'lesson') {
router.push(`/lessons/${item.id}`);
} else { } else {
router.push(`/notes/${item.id}`); router.push(`/notes/${item.id}`);
} }
@@ -417,6 +427,10 @@ onUnmounted(() => {
<Workflow :size="16" /> <Workflow :size="16" />
Process Process
</button> </button>
<button @click="createNew('lesson')">
<Lightbulb :size="16" />
Lesson
</button>
</div> </div>
</div> </div>
@@ -574,6 +588,7 @@ onUnmounted(() => {
<span v-else-if="item.note_type === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span> <span v-else-if="item.note_type === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span>
<span v-else-if="item.note_type === 'process'">Process</span> <span v-else-if="item.note_type === 'process'">Process</span>
<span v-else-if="item.note_type === 'snippet'">Snippet</span> <span v-else-if="item.note_type === 'snippet'">Snippet</span>
<span v-else-if="item.note_type === 'lesson'">Lesson</span>
</span> </span>
<!-- Kind sits BESIDE the type badge, not inside it: the type badge <!-- Kind sits BESIDE the type badge, not inside it: the type badge
speaks the vocabulary of this view's type filter (note / task / speaks the vocabulary of this view's type filter (note / task /
@@ -962,7 +977,11 @@ onUnmounted(() => {
isn't an alarm reads better as plain. Standard body pair, so the contrast isn't an alarm reads better as plain. Standard body pair, so the contrast
is the one the palette already guarantees. */ is the one the palette already guarantees. */
.badge--snippet, .badge--snippet,
.badge--process { background: var(--fs-surface-raised); color: var(--fs-text-secondary); } .badge--process,
/* A lesson joins the neutral pair for the same reason, and one of its own: it
binds nobody. A hue here would make the softest record in the corpus look
like the loudest, beside a rule that actually is binding. */
.badge--lesson { background: var(--fs-surface-raised); color: var(--fs-text-secondary); }
.k-card-body { flex: 1; padding-right: 40px; } .k-card-body { flex: 1; padding-right: 40px; }
.k-card-title { .k-card-title {
+288
View File
@@ -0,0 +1,288 @@
<script setup lang="ts">
/**
* Read a LESSON, and see both ends of what it came from.
*
* THE TRIGGER LEADS. A lesson is retrieved by the situation it applies to, so
* the page opens with that situation rather than with the claim — the same
* ordering the editor uses, and for the same reason: the trigger is the half
* a reader needs first to decide whether this is for them.
*
* GLOBAL BY DEFAULT HAS TO BE LEGIBLE. A lesson written on one project will
* surface on another, which reads as a bug unless the page says why. The
* origin line does exactly that, and says it as a property of the kind rather
* than as an apology.
*
* IT BINDS NOBODY, and the page says so once, plainly. That sentence is the
* difference between this kind and a rule, and it is the reason the kind
* exists (#3727) — sessions were proposing rules for things that should never
* have bound anyone.
*/
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { apiErrorMessage } from "@/api/client";
import { deleteLesson, getLesson, type Lesson } from "@/api/lessons";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import TagPill from "@/components/TagPill.vue";
import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown";
const route = useRoute();
const router = useRouter();
const toast = useToastStore();
const lesson = ref<Lesson | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);
const confirming = ref(false);
const lessonId = computed(() => Number(route.params.id));
/** The insight rendered — the body minus the lines the service composed, so
* the trigger is not printed twice on a page that already leads with it. */
const insightHtml = computed(() =>
lesson.value?.insight ? renderMarkdown(lesson.value.insight) : "",
);
/** Where each source opens. A task and a note live at different routes, and a
* link that guesses wrong is worse than one that is plain. */
function sourceHref(rec: { id: number; is_task?: boolean; note_type?: string }) {
if (rec.is_task) return `/tasks/${rec.id}`;
if (rec.note_type === "snippet") return `/snippets/${rec.id}`;
if (rec.note_type === "lesson") return `/lessons/${rec.id}`;
return `/notes/${rec.id}`;
}
async function load() {
loading.value = true;
error.value = null;
try {
lesson.value = await getLesson(lessonId.value);
} catch (e) {
error.value = apiErrorMessage(e, "Failed to load this lesson");
} finally {
loading.value = false;
}
}
async function remove() {
confirming.value = false;
try {
await deleteLesson(lessonId.value);
toast.show("Lesson moved to trash");
router.push("/knowledge?type=lesson");
} catch (e) {
toast.show(apiErrorMessage(e, "Failed to delete this lesson"), "error");
}
}
watch(lessonId, load);
onMounted(load);
</script>
<template>
<div class="lesson-detail">
<p v-if="loading" class="ld-muted">Loading</p>
<p v-else-if="error" class="ld-error" role="alert">{{ error }}</p>
<article v-else-if="lesson">
<header class="ld-head">
<span class="ld-kind">Lesson</span>
<!-- Said once, plainly. It is the whole difference from a rule. -->
<span class="ld-binds">binds nobody</span>
</header>
<!-- The trigger leads, in its own block: it is what a reader needs first
to decide whether this applies to them. -->
<section class="ld-trigger">
<h2 class="ld-trigger-label">When this applies</h2>
<p class="ld-trigger-text">{{ lesson.when_to_apply }}</p>
</section>
<h1 class="ld-what">{{ lesson.what }}</h1>
<div
v-if="insightHtml"
class="ld-insight markdown-body"
v-html="insightHtml"
/>
<p v-else class="ld-muted ld-empty">
No detail was recorded the claim above is the whole lesson.
</p>
<!-- What taught it. The provenance is the point: a lesson that loses its
incidents loses its evidence. -->
<section
v-if="lesson.learned_from_records?.length"
class="ld-panel"
>
<h2 class="ld-panel-label">Learned from</h2>
<ul class="ld-sources">
<li v-for="rec in lesson.learned_from_records" :key="rec.id">
<router-link :to="sourceHref(rec)" class="ld-link">
{{ rec.title }}
</router-link>
<span v-if="rec.status" class="ld-source-status">{{ rec.status }}</span>
</li>
</ul>
</section>
<section v-if="lesson.tags?.length" class="ld-tags">
<TagPill v-for="t in lesson.tags" :key="t" :tag="t" />
</section>
<!-- Global-by-default, stated as a property rather than an apology. A
lesson meeting you on a project it was not written on is the kind
working, and the page has to say so or it reads as a bug. -->
<footer class="ld-origin">
<p v-if="lesson.project_id">
Learned on another project, and offered everywhere a lesson is
retrieved by the situation it names, never by where it was written.
</p>
<p v-else>
Not tied to a project. Offered wherever the situation it names comes
up.
</p>
</footer>
<div class="ld-actions">
<router-link :to="`/lessons/${lesson.id}/edit`" class="ld-primary">
Edit
</router-link>
<button type="button" class="ld-ghost" @click="confirming = true">
Delete
</button>
</div>
</article>
<ConfirmDialog
v-if="confirming"
title="Delete this lesson?"
message="It moves to the trash and can be restored."
@confirm="remove"
@cancel="confirming = false"
/>
</div>
</template>
<style scoped>
.lesson-detail {
max-width: 780px;
margin: 0 auto;
padding: var(--fs-layout-page-pad);
color: var(--fs-text-primary);
}
.ld-head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
/* Neutral, matching the browse badge. A hue here would make the softest
record in the corpus look like the loudest. */
.ld-kind {
padding: 0.15rem 0.5rem;
border-radius: 10px;
background: var(--fs-surface-raised);
color: var(--fs-text-secondary);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 500;
}
.ld-binds { color: var(--fs-text-tertiary); font-size: 0.8rem; }
.ld-trigger {
padding: 0.9rem 1rem;
border-left: 2px solid var(--fs-accent);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-raised);
margin-bottom: 1rem;
}
.ld-trigger-label {
margin: 0 0 0.3rem;
font-size: 0.72rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--fs-text-tertiary);
}
.ld-trigger-text { margin: 0; font-size: 0.98rem; line-height: 1.5; }
.ld-what {
margin: 0 0 1.25rem;
font-size: 1.25rem;
font-weight: 500;
line-height: 1.35;
}
.ld-insight { line-height: 1.6; font-size: 0.93rem; }
.ld-empty { font-style: italic; }
.ld-panel {
margin-top: 1.75rem;
padding-top: 1rem;
border-top: 1px solid var(--fs-border-color);
}
.ld-panel-label {
margin: 0 0 0.5rem;
font-size: 0.72rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--fs-text-tertiary);
}
.ld-sources { margin: 0; padding-left: 1.1rem; }
.ld-sources li { margin-bottom: 0.3rem; font-size: 0.9rem; }
.ld-link { color: var(--fs-accent); }
.ld-source-status {
margin-left: 0.4rem;
color: var(--fs-text-tertiary);
font-size: 0.78rem;
}
.ld-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 1.25rem; }
.ld-origin {
margin-top: 1.5rem;
color: var(--fs-text-secondary);
font-size: 0.82rem;
line-height: 1.5;
}
.ld-origin p { margin: 0; }
.ld-actions {
display: flex;
gap: 0.6rem;
margin-top: 1.75rem;
padding-top: 1rem;
border-top: 1px solid var(--fs-border-color);
}
.ld-primary {
padding: 0.45rem 1rem;
border-radius: var(--fs-radius-sm);
background: var(--fs-accent);
color: var(--fs-accent-fg);
font-size: 0.88rem;
}
.ld-ghost {
padding: 0.45rem 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.88rem;
cursor: pointer;
}
.ld-muted { color: var(--fs-text-secondary); font-size: 0.88rem; }
.ld-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;
}
</style>
+385
View File
@@ -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>
+8
View File
@@ -23,6 +23,7 @@ import WordCount from "@/components/WordCount.vue";
import TagInput from "@/components/TagInput.vue"; import TagInput from "@/components/TagInput.vue";
import ProjectSelector from "@/components/ProjectSelector.vue"; import ProjectSelector from "@/components/ProjectSelector.vue";
import MilestoneSelector from "@/components/MilestoneSelector.vue"; import MilestoneSelector from "@/components/MilestoneSelector.vue";
import LessonsTaughtPanel from "@/components/LessonsTaughtPanel.vue";
import TaskLogSection from "@/components/TaskLogSection.vue"; import TaskLogSection from "@/components/TaskLogSection.vue";
import DiffView from "@/components/DiffView.vue"; import DiffView from "@/components/DiffView.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue"; import ConfirmDialog from "@/components/ConfirmDialog.vue";
@@ -546,6 +547,13 @@ useEditorGuards(dirty, save);
<!-- Work log --> <!-- Work log -->
<TaskLogSection v-if="taskId" :task-id="taskId" class="body-log" /> <TaskLogSection v-if="taskId" :task-id="taskId" class="body-log" />
<!-- What came OUT of this record. The forward link is typed by
the lesson's author; this direction has no author and so
tends never to get built — but a reader opening an old
issue wants to know what was learned from it. Silent when
there is nothing, because most records taught no lesson. -->
<LessonsTaughtPanel v-if="taskId" :record-id="taskId" />
<!-- Applicable rules (plan tasks only) --> <!-- Applicable rules (plan tasks only) -->
<PlanRulesPanel <PlanRulesPanel
v-if="store.currentTask?.task_kind === 'plan' && store.currentTask?.project_id" v-if="store.currentTask?.task_kind === 'plan' && store.currentTask?.project_id"
+5
View File
@@ -177,6 +177,11 @@ async def get_lesson_route(lesson_id: int):
s.to_dict() s.to_dict()
for s in await systems_svc.list_record_systems(note.user_id, lesson_id) for s in await systems_svc.list_record_systems(note.user_id, lesson_id)
] ]
# Resolved, not bare ids: "#4181" on a page tells a reader nothing about
# whether it is worth opening, and the provenance is the point of a lesson.
out["learned_from_records"] = await lessons_svc.source_records(
uid, out["learned_from"]
)
out.update(await describe_provenance(uid, note)) out.update(await describe_provenance(uid, note))
out["usage"] = (await usage_for_notes([lesson_id])).get( out["usage"] = (await usage_for_notes([lesson_id])).get(
lesson_id, empty_usage() lesson_id, empty_usage()
+44
View File
@@ -445,6 +445,50 @@ def lesson_to_dict(note) -> dict:
} }
async def source_records(user_id: int, ids: list[int]) -> list[dict]:
"""The records that taught a lesson, resolved to something linkable.
`learned_from` is a list of bare ids, which is right for storage and
useless on a page: "#4181" tells a reader nothing about whether it is
worth opening. This resolves each to its title and kind so the UI can
label the link, and so a source that has been deleted simply drops out
rather than rendering a link to nothing.
One query for the whole list, not one per id — a lesson with six sources
would otherwise be six round trips to draw one panel.
Share-aware (rule 78). Order follows `ids`, because that order is the
writer's: the first source is the one they reached for first.
"""
from sqlalchemy import select
from scribe.services.access import readable_notes_clause
wanted = normalize_sources(ids)
if not wanted:
return []
async with async_session() as session:
result = await session.execute(
select(Note)
.where(Note.id.in_(wanted))
.where(Note.deleted_at.is_(None))
.where(readable_notes_clause(user_id))
)
found = {n.id: n for n in result.scalars().all()}
return [
{
"id": n.id,
"title": n.title,
"note_type": n.note_type,
"is_task": n.status is not None,
"task_kind": n.task_kind,
"status": n.status,
}
for i in wanted
if (n := found.get(i)) is not None
]
async def lessons_taught_by(user_id: int, record_id: int, limit: int = 20): async def lessons_taught_by(user_id: int, record_id: int, limit: int = 20):
"""The lessons drawn FROM one record — the reverse of `learned_from`. """The lessons drawn FROM one record — the reverse of `learned_from`.