CI & Build / Python lint (push) Successful in 8s
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 57s
CI & Build / Python tests (push) Successful in 1m41s
CI & Build / Build & push image (push) Successful in 36s
The lesson slot has recorded surfaced-vs-opened since it shipped. Nothing showed it. `get_lesson`'s REST door attached `usage` to the payload and no view rendered it; the listing did not attach it at all, and neither MCP door did. #4196 asks when a lesson that keeps getting followed should become a rule, and names the trap in the same breath: raw frequency cannot separate "this should bind" from "this trigger is too broad", and the second is the commoner reading by a wide margin. Surfaced-AND-opened can separate them. Neither question is answerable by a reader who cannot see the numbers, which is why this is the first step and not the threshold. NO THRESHOLD IS PROPOSED HERE, deliberately. The corpus today is 10 lessons with 7 recorded surfacings and 3 opens, over about fifteen hours of usage data. A promotion rule fitted to that would be fitting noise — #3311's failure, and the warning lesson #4228 was written to carry. `UsageBadge` already declines to render a verdict under three surfacings for the same reason. So #4196 stays open: its subject, the promotion path, is still unbuilt. What lands is the evidence it needs. - REST `GET /api/lessons` and MCP `list_lessons` attach `usage` to every row, from one aggregate per page rather than a per-row read, which would be N+1 by construction. Every row carries the key zero-filled, so "never surfaced" is a state a reader can see rather than a missing field they have to interpret. - MCP `get_lesson` attaches it too, and reads it BEFORE recording its own pull. That door records a pull on every open — it has to, or the kind sits permanently at zero — which makes the order load-bearing in a way it is not for a kind that only counts. The REST detail door already ordered it this way; the two now agree about what the number means. - `LessonDetailView` renders `UsageBadge` (snippet #3460) rather than re-spelling the chip, with the advice keyed to this kind: a lesson that is repeatedly offered and never opened is usually keyed to a situation nobody is in, so it points at re-keying `when_to_apply`, not at deleting the claim. Guards, in the two styles this pair of doors already uses: the MCP side driven behaviourally through mocks, including the call ORDER for `get_lesson`; the REST side on structure like its siblings in test_lesson_rest_door.py, because the route is decorated and returns a Quart response. Rule 167's falsifier is included. KNOWN GAP, not fixed here: `KnowledgeView` is the only lesson LIST in the UI and it reads `/knowledge`, not `/lessons` — so the REST listing change reaches `frontend/src/api/lessons.ts::listLessons`, which currently has no consumer. The agent-facing listing does reach a reader today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
309 lines
9.4 KiB
Vue
309 lines
9.4 KiB
Vue
<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 UsageBadge from "@/components/UsageBadge.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>
|
|
|
|
<div class="ld-what-row">
|
|
<h1 class="ld-what">{{ lesson.what }}</h1>
|
|
<!-- Lessons collected surfaced-vs-opened from the day the slot shipped
|
|
and showed it nowhere (#4196). It belongs here above anywhere
|
|
else: the reading that matters is almost never "delete this", it
|
|
is "the trigger is keyed to a situation nobody is in", and the
|
|
place to act on that is the lesson you are already looking at. -->
|
|
<UsageBadge
|
|
:usage="lesson.usage"
|
|
noun="lesson"
|
|
dead-weight-advice="Repeatedly offered and never opened usually means the trigger fires on the wrong situation — re-key `when_to_apply` rather than deleting the claim."
|
|
/>
|
|
</div>
|
|
|
|
<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-row {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: var(--fs-space-2);
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.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>
|