CI & Build / Python lint (push) Successful in 9s
CI & Build / Plugin hooks (push) Successful in 22s
CI & Build / TypeScript typecheck (push) Successful in 1m0s
CI & Build / integration (push) Successful in 1m55s
CI & Build / Python tests (push) Successful in 2m20s
CI & Build / Build & push image (push) Successful in 1m7s
Components, stores, api clients, router, types and shared CSS now state what each piece is and why, without the milestone that introduced it. diff.ts names its consumers instead of the copy history; stores/notes and tasks state their current scope instead of the cleanup that set it; theme.css states the token practice instead of the sweep that established it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
298 lines
12 KiB
Vue
298 lines
12 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from "vue";
|
||
import { useMomentsStore } from "@/stores/moments";
|
||
import MomentProposals from "@/components/MomentProposals.vue";
|
||
import type { MomentAction, MomentUsage } from "@/api/moments";
|
||
import { fmtDate } from "@/utils/dateFormat";
|
||
|
||
/**
|
||
* The human door onto moments: every moment in the
|
||
* catalog, the actions that reach it on this install, how many rules ride on
|
||
* it, and what reaching it has delivered. The session's own door is
|
||
* `list_moments` / `map_action` / `unmap_action`, and both call the same
|
||
* services — this view exists so a person can see and change everything an
|
||
* agent can, without asking one to.
|
||
*/
|
||
const store = useMomentsStore();
|
||
|
||
const data = computed(() => store.data);
|
||
const catalog = computed(() => data.value?.moments ?? []);
|
||
const days = computed(() => data.value?.usage.days ?? 30);
|
||
const usageFailed = computed(() => !!data.value?.usage.moment_usage_failed);
|
||
|
||
function actionsFor(moment: string): MomentAction[] {
|
||
return data.value?.actions[moment] ?? [];
|
||
}
|
||
function mounted(moment: string): number {
|
||
return data.value?.mounted[moment] ?? 0;
|
||
}
|
||
function usage(moment: string): MomentUsage | null {
|
||
return data.value?.usage.by_moment[moment] ?? null;
|
||
}
|
||
|
||
// A named procedure is its own moment (`skill.<name>`), so it appears here
|
||
// only once something is mounted on it or has been delivered at it — the
|
||
// catalog cannot list procedures it has never heard of.
|
||
const procedureMoments = computed(() => {
|
||
const names = new Set<string>([
|
||
...Object.keys(data.value?.mounted ?? {}),
|
||
...Object.keys(data.value?.usage.by_moment ?? {}),
|
||
]);
|
||
return [...names].filter((n) => n.startsWith("skill.")).sort();
|
||
});
|
||
|
||
function actionLabel(a: { tool: string; match: string }): string {
|
||
return a.match ? `${a.tool} · ${a.match}` : a.tool;
|
||
}
|
||
|
||
const busy = ref(false);
|
||
|
||
async function run(fn: () => Promise<unknown>) {
|
||
busy.value = true;
|
||
try {
|
||
await fn();
|
||
} catch {
|
||
// The store has already said why; the row stays as it was.
|
||
} finally {
|
||
busy.value = false;
|
||
}
|
||
}
|
||
|
||
function removeAction(moment: string, a: MomentAction) {
|
||
// A shipped default is switched off rather than deleted, and comes back
|
||
// with "Restore" below — worth a confirm, since every later session on
|
||
// this install loses the moment for that action.
|
||
if (a.via === "default"
|
||
&& !confirm(`Stop ${actionLabel(a)} reaching ${moment} on this install? You can restore it below.`)) {
|
||
return;
|
||
}
|
||
void run(() => store.unmap({ tool: a.tool, match: a.match, moment, reason: "Removed in Settings." }));
|
||
}
|
||
|
||
function restore(r: { tool: string; match: string; moment: string }) {
|
||
void run(() => store.map({ tool: r.tool, match: r.match, moment: r.moment }));
|
||
}
|
||
|
||
// ── Add a mapping ─────────────────────────────────────────────────────────
|
||
const draftTool = ref("");
|
||
const draftMatch = ref("");
|
||
const draftMoment = ref("");
|
||
const draftReason = ref("");
|
||
const canAdd = computed(() => !!draftTool.value.trim() && !!draftMoment.value && !busy.value);
|
||
|
||
async function addMapping() {
|
||
if (!canAdd.value) return;
|
||
busy.value = true;
|
||
try {
|
||
await store.map({
|
||
tool: draftTool.value.trim(),
|
||
match: draftMatch.value.trim(),
|
||
moment: draftMoment.value,
|
||
reason: draftReason.value.trim(),
|
||
});
|
||
draftTool.value = "";
|
||
draftMatch.value = "";
|
||
draftReason.value = "";
|
||
} catch {
|
||
// Kept as typed, so the refusal the store showed can be acted on.
|
||
} finally {
|
||
busy.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(() => store.load(true));
|
||
</script>
|
||
|
||
<template>
|
||
<div class="moments-settings">
|
||
<p v-if="store.loading && !data" class="empty-msg">Loading moments…</p>
|
||
<p v-else-if="store.failed && !data" class="error-msg">
|
||
The moment catalog could not be loaded.
|
||
<button type="button" class="btn-text" @click="store.load(true)">Try again</button>
|
||
</p>
|
||
|
||
<template v-else-if="data">
|
||
<p class="window-note">
|
||
Deliveries and opens over the last {{ days }} days. “Opened” counts the rules an agent
|
||
went on to read — an upper bound, since a rule delivered at two moments and read once
|
||
counts at both.
|
||
</p>
|
||
<p v-if="usageFailed" class="error-msg">
|
||
The delivery counts could not be read, so they are missing below — not zero.
|
||
</p>
|
||
<p v-if="data.mounted_failed" class="error-msg">
|
||
The mount counts could not be read, so they are missing below — not zero.
|
||
</p>
|
||
|
||
<MomentProposals />
|
||
|
||
<ul class="moment-list">
|
||
<li v-for="m in catalog" :key="m.name" class="moment-row">
|
||
<div class="moment-head">
|
||
<code class="moment-name">{{ m.name }}</code>
|
||
<span class="moment-stats">
|
||
<span :class="{ 'stat-none': !mounted(m.name) }">
|
||
{{ mounted(m.name) ? `${mounted(m.name)} ${mounted(m.name) === 1 ? "rule" : "rules"} mounted` : "nothing mounted" }}
|
||
</span>
|
||
<template v-if="usage(m.name)">
|
||
<span>delivered {{ usage(m.name)!.delivered }}</span>
|
||
<span>opened {{ usage(m.name)!.opened }} of {{ usage(m.name)!.rules }}</span>
|
||
<span v-if="usage(m.name)!.last_delivered_at" class="stat-when">
|
||
last {{ fmtDate(usage(m.name)!.last_delivered_at!) }}
|
||
</span>
|
||
</template>
|
||
</span>
|
||
</div>
|
||
<p class="moment-desc">{{ m.means }}</p>
|
||
<div class="moment-actions">
|
||
<span class="actions-label">Reached by</span>
|
||
<span v-if="!actionsFor(m.name).length" class="stat-none">
|
||
no action here — {{ m.reached_by }}
|
||
</span>
|
||
<span
|
||
v-for="a in actionsFor(m.name)"
|
||
:key="`${a.via}:${a.tool}:${a.match}`"
|
||
:class="['action-chip', { 'is-install': a.via === 'install' }]"
|
||
:title="a.via === 'install' ? 'Added on this install' : 'Ships with Scribe'"
|
||
>
|
||
<code>{{ actionLabel(a) }}</code>
|
||
<button
|
||
type="button"
|
||
class="chip-remove"
|
||
:disabled="busy"
|
||
:aria-label="a.via === 'install'
|
||
? `Remove ${actionLabel(a)} from ${m.name}`
|
||
: `Switch off the shipped ${actionLabel(a)} for ${m.name}`"
|
||
@click="removeAction(m.name, a)"
|
||
>×</button>
|
||
</span>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
|
||
<template v-if="procedureMoments.length">
|
||
<h3 class="sub-title">Named procedures</h3>
|
||
<ul class="moment-list">
|
||
<li v-for="name in procedureMoments" :key="name" class="moment-row">
|
||
<div class="moment-head">
|
||
<code class="moment-name">{{ name }}</code>
|
||
<span class="moment-stats">
|
||
<span :class="{ 'stat-none': !mounted(name) }">
|
||
{{ mounted(name) ? `${mounted(name)} ${mounted(name) === 1 ? "rule" : "rules"} mounted` : "nothing mounted" }}
|
||
</span>
|
||
<template v-if="usage(name)">
|
||
<span>delivered {{ usage(name)!.delivered }}</span>
|
||
<span>opened {{ usage(name)!.opened }} of {{ usage(name)!.rules }}</span>
|
||
</template>
|
||
</span>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</template>
|
||
|
||
<template v-if="data.removed_defaults.length">
|
||
<h3 class="sub-title">Shipped mappings switched off here</h3>
|
||
<ul class="removed-list">
|
||
<li v-for="r in data.removed_defaults" :key="r.id" class="removed-row">
|
||
<code>{{ actionLabel(r) }}</code>
|
||
<span class="removed-arrow">→</span>
|
||
<code>{{ r.moment }}</code>
|
||
<span v-if="r.reason" class="removed-reason">{{ r.reason }}</span>
|
||
<button type="button" class="btn-text" :disabled="busy" @click="restore(r)">Restore</button>
|
||
</li>
|
||
</ul>
|
||
</template>
|
||
|
||
<form class="add-mapping" @submit.prevent="addMapping">
|
||
<h3 class="sub-title">Add an action</h3>
|
||
<p class="field-hint">
|
||
When something you do plainly happens at a moment and nothing fired — your own deploy
|
||
script, a tool from another MCP server. For a command tool, the match is how the command
|
||
starts (<code>make ship</code>); for any other tool, its arguments as
|
||
<code>field=value</code> pairs. Leave it empty to map every call.
|
||
</p>
|
||
<div class="add-grid">
|
||
<label>
|
||
Tool
|
||
<input v-model="draftTool" class="fs-input" placeholder="Bash, or an MCP tool's name" />
|
||
</label>
|
||
<label>
|
||
Match
|
||
<input v-model="draftMatch" class="fs-input" placeholder="make ship · status=done · empty for every call" />
|
||
</label>
|
||
<label>
|
||
Moment
|
||
<select v-model="draftMoment" class="fs-input">
|
||
<option value="" disabled>Choose a moment</option>
|
||
<option v-for="m in catalog" :key="m.name" :value="m.name">{{ m.name }}</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Why <span class="optional">(optional)</span>
|
||
<input v-model="draftReason" class="fs-input" placeholder="what it is for here" />
|
||
</label>
|
||
</div>
|
||
<button type="submit" class="btn-primary" :disabled="!canAdd">Add action</button>
|
||
</form>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.moments-settings { display: flex; flex-direction: column; gap: var(--fs-space-3); }
|
||
.window-note { margin: 0; font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); line-height: var(--fs-leading-body); }
|
||
|
||
.moment-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
|
||
.moment-row {
|
||
padding: var(--fs-space-3);
|
||
background: var(--fs-surface-page);
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: var(--fs-radius-md);
|
||
}
|
||
.moment-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--fs-space-2); }
|
||
.moment-head .moment-name { font-size: var(--fs-size-body-sm); }
|
||
.moment-stats {
|
||
margin-left: auto;
|
||
display: flex; flex-wrap: wrap; gap: var(--fs-space-3);
|
||
font-size: var(--fs-size-tiny); color: var(--fs-text-secondary);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
/* Nothing mounted is the ordinary state of most moments, not a fault. */
|
||
.stat-none { color: var(--fs-text-tertiary); font-style: italic; }
|
||
.stat-when { color: var(--fs-text-tertiary); }
|
||
.moment-desc { margin: var(--fs-space-1) 0 var(--fs-space-2); font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); line-height: var(--fs-leading-body); }
|
||
|
||
.moment-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--fs-space-2); font-size: var(--fs-size-tiny); }
|
||
.actions-label { color: var(--fs-text-tertiary); text-transform: uppercase; letter-spacing: var(--fs-tracking-tiny); }
|
||
.action-chip {
|
||
display: inline-flex; align-items: center; gap: var(--fs-space-1);
|
||
padding: 0.05rem 0.45rem;
|
||
border: 1px solid var(--fs-border-color);
|
||
border-radius: var(--fs-radius-sm);
|
||
color: var(--fs-text-secondary);
|
||
}
|
||
.action-chip code { font-family: var(--fs-font-mono); }
|
||
/* This install's own, set apart from the shipped ones the way the tuning
|
||
trail marks the operator's changes: a dashed edge, no accent. */
|
||
.action-chip.is-install { border-style: dashed; color: var(--fs-text-primary); }
|
||
|
||
.sub-title { margin: var(--fs-space-3) 0 var(--fs-space-1); font-size: 0.95rem; color: var(--fs-text-primary); }
|
||
|
||
.removed-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-1); }
|
||
.removed-row { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--fs-space-2); font-size: var(--fs-size-body-sm); }
|
||
.removed-row code { font-family: var(--fs-font-mono); color: var(--fs-text-secondary); text-decoration: line-through; }
|
||
.removed-arrow { color: var(--fs-text-tertiary); }
|
||
.removed-reason { color: var(--fs-text-tertiary); font-size: var(--fs-size-tiny); }
|
||
|
||
.add-mapping { display: flex; flex-direction: column; gap: var(--fs-space-2); align-items: flex-start; }
|
||
.add-grid {
|
||
width: 100%;
|
||
display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||
gap: var(--fs-space-2) var(--fs-space-3);
|
||
}
|
||
.add-grid label { display: flex; flex-direction: column; gap: var(--fs-space-1); font-size: var(--fs-size-body-sm); color: var(--fs-text-primary); }
|
||
.optional { color: var(--fs-text-tertiary); font-weight: normal; }
|
||
</style>
|
||
|
||
<style src="@/assets/moments-shared.css" />
|