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 2m2s
CI & Build / Build & push image (push) Successful in 45s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
295 lines
12 KiB
Vue
295 lines
12 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from "vue";
|
||
import { useMomentsStore } from "@/stores/moments";
|
||
import type { MomentAction, MomentUsage } from "@/api/moments";
|
||
import { fmtDate } from "@/utils/dateFormat";
|
||
|
||
/**
|
||
* The human door onto moments (milestone 458 step 6): 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>
|
||
|
||
<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" />
|