CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 52s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m36s
CI & Build / Build & push image (push) Canceled after 35s
plugin-vue throws "Cannot read properties of undefined (reading 'scoped')"
when several SFCs share one src stylesheet. vue-tsc passed, so only the
image job caught it; :dev has not rebuilt since 22b7a92.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
197 lines
8.0 KiB
Vue
197 lines
8.0 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* What Scribe has changed about how it works with you.
|
|
*
|
|
* THE RISK THIS EXISTS FOR (milestone 399). A preference is the one record
|
|
* kind the agent rewrites on its own, mid-work, without asking — which is
|
|
* what keeps it current and what makes it dangerous. An agent misreads one
|
|
* session, rewrites a preference, and follows the rewritten version forever
|
|
* while the operator never sees the moment it changed. That is worse than
|
|
* having no preference at all: a confident wrong answer wearing the
|
|
* operator's own authority.
|
|
*
|
|
* `rule_versions` already recorded every rewrite. What it could not do is
|
|
* ARRIVE. A history you open one rule at a time, having first suspected that
|
|
* rule, is not oversight — so this pane is the PUSH half, and it sits beside
|
|
* the staleness sweep for the same reason that does: drift belongs to no one
|
|
* rulebook.
|
|
*
|
|
* Cross-cutting, and deliberately not a filter on the per-topic rule list —
|
|
* that list shows one topic of one rulebook, so filtering it would silently
|
|
* under-report, which is the exact failure this surface exists to catch.
|
|
*/
|
|
import { onMounted, ref } from "vue";
|
|
import DiffView from "@/components/DiffView.vue";
|
|
import { computeDiff } from "@/utils/diff";
|
|
import { useRulebooksStore } from "@/stores/rulebooks";
|
|
import { useToastStore } from "@/stores/toast";
|
|
import type { PreferenceDrift } from "@/api/rulebooks";
|
|
|
|
const emit = defineEmits<{ "open-rule": [id: number] }>();
|
|
|
|
const store = useRulebooksStore();
|
|
const toast = useToastStore();
|
|
const openId = ref<number | null>(null);
|
|
const busyId = ref<number | null>(null);
|
|
|
|
/** Old on the left, new on the right — the direction a reader expects of
|
|
* "what changed", and the opposite of the rule history panel, which is
|
|
* answering "what did it used to say" from the current text backwards. */
|
|
function diffFor(row: PreferenceDrift) {
|
|
return computeDiff(row.previous.statement, row.current.statement);
|
|
}
|
|
|
|
function triggerChanged(row: PreferenceDrift): boolean {
|
|
return row.previous.when_to_apply !== row.current.when_to_apply;
|
|
}
|
|
|
|
function stamp(iso: string | null): string {
|
|
return iso ? iso.slice(0, 10) : "";
|
|
}
|
|
|
|
function toggle(row: PreferenceDrift) {
|
|
openId.value = openId.value === row.rule.id ? null : row.rule.id;
|
|
}
|
|
|
|
/** The veto. One action, because a veto that costs more than shrugging is
|
|
* not really supervision — and nothing is lost either way: the restore is
|
|
* itself an edit, so the rewrite stays in the preference's history with the
|
|
* revert recorded after it. */
|
|
async function restore(row: PreferenceDrift) {
|
|
busyId.value = row.rule.id;
|
|
try {
|
|
await store.restoreVersion(row.rule.id, row.previous.id);
|
|
toast.show(`Put “${row.previous.title}” back`, "success");
|
|
openId.value = null;
|
|
} catch {
|
|
toast.show("Could not put that wording back", "error");
|
|
} finally {
|
|
busyId.value = null;
|
|
}
|
|
}
|
|
|
|
onMounted(() => store.fetchDrift());
|
|
</script>
|
|
|
|
<template>
|
|
<section class="pane drift">
|
|
<header>
|
|
<h2>Recent changes</h2>
|
|
<p class="sweep-lede">
|
|
Preferences Scribe rewrote while working, most recently changed first. A
|
|
preference is how you want work done, so sessions keep it current
|
|
without asking — this is where you see what they decided. Rules are not
|
|
here: those change when you change them.
|
|
</p>
|
|
</header>
|
|
|
|
<p v-if="store.loading" class="sweep-state">Loading…</p>
|
|
|
|
<!-- Nothing changed is the ordinary state and must not read as a fault. -->
|
|
<p v-else-if="!store.drift.length" class="sweep-state empty">
|
|
Nothing has been rewritten. A preference appears here the first time a
|
|
session changes one — until then there is nothing to review.
|
|
</p>
|
|
|
|
<ol v-else class="sweep-rows">
|
|
<li v-for="row in store.drift" :key="row.rule.id" class="sweep-row">
|
|
<div class="sweep-row-head">
|
|
<button class="sweep-row-title" @click="emit('open-rule', row.rule.id)">
|
|
{{ row.rule.title }}
|
|
</button>
|
|
<span class="when">{{ stamp(row.previous.created_at) }}</span>
|
|
</div>
|
|
|
|
<!-- The provenance, named rather than numbered: a bare id reads as
|
|
complete to the writer and as homework to the reader. -->
|
|
<p v-if="row.taught_by" class="taught">
|
|
Learned from <em>{{ row.taught_by.title }}</em>
|
|
<span class="taught-id">#{{ row.taught_by.id }}</span>
|
|
</p>
|
|
<p v-else class="taught untaught">
|
|
Nothing recorded what taught this change.
|
|
</p>
|
|
|
|
<button class="expand" :aria-expanded="openId === row.rule.id" @click="toggle(row)">
|
|
{{ openId === row.rule.id ? "Hide what changed" : "See what changed" }}
|
|
</button>
|
|
|
|
<div v-if="openId === row.rule.id" class="detail">
|
|
<p v-if="triggerChanged(row)" class="trigger-moved">
|
|
Its trigger changed too, so it now arrives at a different moment.
|
|
<span class="was">Was:</span> {{ row.previous.when_to_apply || "nothing" }}
|
|
</p>
|
|
<DiffView v-if="diffFor(row).length" :diff="diffFor(row)" />
|
|
<p v-else class="sweep-state">
|
|
The statement is unchanged — this edit moved another field.
|
|
</p>
|
|
<div class="sweep-actions">
|
|
<button
|
|
:disabled="busyId === row.rule.id"
|
|
@click="restore(row)"
|
|
>Put the old wording back</button>
|
|
<span class="actions-note">
|
|
Kept, not erased: this is recorded as another edit, so both
|
|
wordings stay in the preference's history.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
</ol>
|
|
|
|
<p v-if="store.drift.length" class="footnote">
|
|
One row per preference, carrying its latest rewrite. A preference changed
|
|
several times shows the most recent one — its full history is in its
|
|
editor, under <strong>Edit history</strong>.
|
|
</p>
|
|
</section>
|
|
</template>
|
|
|
|
<style src="@/assets/rules-shared.css" />
|
|
<style scoped>
|
|
.drift { display: flex; flex-direction: column; gap: var(--fs-space-3); }
|
|
.when {
|
|
margin-left: auto; font-size: var(--fs-size-tiny);
|
|
color: var(--fs-text-secondary); font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.taught {
|
|
margin: var(--fs-space-2) 0 0; font-size: var(--fs-size-tiny);
|
|
color: var(--fs-text-secondary); line-height: var(--fs-leading-body);
|
|
}
|
|
.taught em { font-style: italic; color: var(--fs-text-primary); }
|
|
.taught-id { margin-left: 0.35rem; color: var(--fs-text-tertiary); font-variant-numeric: tabular-nums; }
|
|
/* Not a warning: every preference written before provenance was required has
|
|
none, and marking those as faults would cry wolf on the whole backlog. */
|
|
.taught.untaught { color: var(--fs-text-tertiary); font-style: italic; }
|
|
|
|
.expand {
|
|
align-self: flex-start; margin-top: var(--fs-space-2);
|
|
background: none; border: none; padding: 0; cursor: pointer;
|
|
font: inherit; font-size: var(--fs-size-tiny); color: var(--fs-text-secondary);
|
|
}
|
|
.expand:hover { color: var(--fs-text-primary); text-decoration: underline; }
|
|
|
|
.detail { margin-top: var(--fs-space-2); display: flex; flex-direction: column; gap: var(--fs-space-2); }
|
|
/* A TINT, not the solid token — `--fs-warning-fg` is defined as warning text
|
|
ON a warning tint, and painting it over solid `--fs-warning` is the
|
|
same-hue contrast failure #3141 records. */
|
|
.trigger-moved {
|
|
margin: 0; font-size: var(--fs-size-tiny); line-height: var(--fs-leading-body);
|
|
color: var(--fs-warning-fg);
|
|
background: color-mix(in srgb, var(--fs-warning) 12%, transparent);
|
|
border-radius: var(--fs-radius-sm); padding: var(--fs-space-2);
|
|
}
|
|
.was { color: var(--fs-text-tertiary); }
|
|
|
|
.actions-note {
|
|
flex: 1; min-width: 18ch; font-size: var(--fs-size-tiny);
|
|
color: var(--fs-text-tertiary); line-height: var(--fs-leading-body);
|
|
}
|
|
|
|
.footnote { margin: 0; max-width: 62ch; font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); line-height: var(--fs-leading-body); }
|
|
/* Drift's buttons sit beside a note inside the expanded detail, not under a
|
|
row of facts. */
|
|
.sweep-actions { align-items: baseline; gap: var(--fs-space-3); flex-wrap: wrap; margin-top: 0; }
|
|
</style>
|