CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 1m12s
CI & Build / integration (push) Successful in 1m41s
CI & Build / Build & push image (push) Successful in 1m1s
Rule 27: no UI, no ship. Three surfaces. THE EDITOR ASKS, but only where the answer can be saved: the fields appear for a plain note and not for a task or a snippet, matching the service gate from step 2 so the form never offers a write the save would reject. The labels are phrased as the QUESTION rather than the field name — "how would someone check this is still true?" and, underneath, "could this become false without anyone editing it?". "Verify with" gets filled in on every note; the question gets filled in on the few that can go stale. `expires_when` appears only once a check exists, and asks for a state rather than a date in the placeholder itself. THE NOTE SHOWS ITS AGE beside the field — "checked 2026-08-28" or "never checked", italic, and nothing at all when no check exists. No red/amber ramp, matching RuleSweepPane: a colour scale would restate the sweep's ordering and force an invented staleness threshold. "Never" is marked because it is categorically different from a date, not a worse one. THE SWEEP is a pane in the Knowledge view, not beside the rules sweep — operator's call, taken over a unified "everything due" surface and over a second pane under /rules. Notes stay where notes live. The cost, accepted knowingly: no single screen shows every unconfirmed record. It REPLACES the feed rather than filtering it, because a facet answers "show me this kind" and this answers "show me what nobody has confirmed" — a question the type chips cannot narrow without under-reporting. Two REST routes for it, since step 3 built only the service and the MCP door. Along the way: NoteEditorView spelled its write payload out at three call sites (save, create, auto-save), so every new field had to be added three times — which is how one of them ends up not carrying it. Now one `payload()` and one `snapshot()`. Known and filed, not fixed: NoteSweepPane copies ~12 scoped CSS rules from RuleSweepPane (#3207). The clean extraction needs prefixed names, because `.age`, `.row-title`, `.lede` and `.actions` all exist scoped in other components and an unscoped global would leak into them — which means editing the shipped rules sweep, blind, inside a step whose acceptance is the operator looking at a different surface.
1118 lines
39 KiB
Vue
1118 lines
39 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
|
import { useRouter } from "vue-router";
|
|
import { apiGet } from "@/api/client";
|
|
import type { TaskKind, TaskStatus, TaskPriority } from "@/types/note";
|
|
import KindBadge from "@/components/KindBadge.vue";
|
|
import NoteSweepPane from "@/components/NoteSweepPane.vue";
|
|
import StatusBadge from "@/components/StatusBadge.vue";
|
|
import PriorityBadge from "@/components/PriorityBadge.vue";
|
|
import GraphView from "@/views/GraphView.vue";
|
|
import {
|
|
FileText,
|
|
CheckSquare,
|
|
Workflow,
|
|
Search,
|
|
Share2,
|
|
ShieldCheck,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
X,
|
|
} from "lucide-vue-next";
|
|
|
|
const router = useRouter();
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
interface KnowledgeItem {
|
|
id: number;
|
|
note_type: "note" | "task" | "process" | "snippet";
|
|
title: string;
|
|
snippet: string;
|
|
tags: string[];
|
|
project_id: number | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
// Set only when another user owns this record — their suggestion, not one of
|
|
// yours. Absent means it's yours.
|
|
shared?: boolean;
|
|
owner?: string | null;
|
|
// Task-specific
|
|
status?: string;
|
|
priority?: string;
|
|
due_date?: string;
|
|
task_kind?: TaskKind;
|
|
}
|
|
|
|
// ─── The facet vocabulary ─────────────────────────────────────────────────────
|
|
// 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 task KIND (`task` for any, else issue / spike) — because that is what this
|
|
// feed actually holds.
|
|
//
|
|
// `plan` is still a valid facet at the API, for the 90 legacy plan-tasks, but
|
|
// 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
|
|
// still reachable under Tasks, wearing a Plan badge.
|
|
type Facet = "" | "note" | "task" | "issue" | "spike" | "snippet" | "process";
|
|
|
|
// The facets that select TASKS. Kinds are subsets of `task`, so any of them
|
|
// means the duplicate report should be comparing tasks.
|
|
const TASK_FACETS = new Set<Facet>(["task", "issue", "spike"]);
|
|
|
|
const FACET_CHIPS: [Exclude<Facet, "">, string][] = [
|
|
["note", "Notes"],
|
|
["task", "Tasks"],
|
|
["issue", "Issues"],
|
|
["spike", "Spikes"],
|
|
["snippet", "Snippets"],
|
|
["process", "Processes"],
|
|
];
|
|
|
|
// ─── View mode ────────────────────────────────────────────────────────────────
|
|
// The sweep is cross-cutting — a note that has gone false does not care which
|
|
// facet it sits under — so it REPLACES the browse list rather than filtering
|
|
// it. Filtering would mean the answer depended on which chip was active, which
|
|
// is the under-reporting the sweep exists to prevent (milestone 317 step 4).
|
|
const sweepActive = ref(false);
|
|
|
|
// ─── Filter state ─────────────────────────────────────────────────────────────
|
|
|
|
const activeType = ref<Facet>("");
|
|
const activeTag = ref("");
|
|
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
|
const searchQuery = ref("");
|
|
let searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
// ─── Near-duplicate report ────────────────────────────────────────────────────
|
|
// On demand, never automatic — a corpus-wide pairwise scan, and most visits to
|
|
// this page aren't a tidy-up (same reasoning as SnippetListView's report).
|
|
|
|
interface DupMember {
|
|
id: number; title: string;
|
|
created_at?: string | null; updated_at?: string | null;
|
|
task_kind?: string | null;
|
|
}
|
|
interface DupGroup {
|
|
note_ids: number[];
|
|
members: DupMember[];
|
|
top_score: number;
|
|
existing_supersessions?: { superseder_id: number; superseded_id: number }[];
|
|
}
|
|
|
|
const dupGroups = ref<DupGroup[]>([]);
|
|
const dupSuggestion = ref("");
|
|
const dupLoading = ref(false);
|
|
const dupChecked = ref(false);
|
|
// The report follows the type filter: viewing tasks — under ANY task facet,
|
|
// including a single kind — checks tasks. Everything else checks notes, the
|
|
// kind with the most to find.
|
|
const dupKind = computed(() => (TASK_FACETS.has(activeType.value) ? "task" : "note"));
|
|
|
|
async function loadDuplicates() {
|
|
dupLoading.value = true;
|
|
try {
|
|
const data = await apiGet<{ groups: DupGroup[]; suggestion: string }>(
|
|
`/api/notes/duplicates?kind=${dupKind.value}`
|
|
);
|
|
dupGroups.value = data.groups;
|
|
dupSuggestion.value = data.suggestion;
|
|
dupChecked.value = true;
|
|
} catch {
|
|
dupChecked.value = false;
|
|
} finally {
|
|
dupLoading.value = false;
|
|
}
|
|
}
|
|
|
|
// A stale report is worse than none: switching the type filter changes which
|
|
// kind the button checks, so the old kind's groups must not linger under it.
|
|
watch(dupKind, () => { dupChecked.value = false; dupGroups.value = []; });
|
|
|
|
// ─── Type counts ──────────────────────────────────────────────────────────────
|
|
|
|
// One number per facet, plus the grand total. Partial because the server sends
|
|
// a key only for a facet it has rows for. Kinds are subsets of `task` and are
|
|
// deliberately absent from `total` — including them would count an issue twice.
|
|
type KnowledgeCounts = Partial<Record<Exclude<Facet, "">, number>> & { total: number };
|
|
const typeCounts = ref<KnowledgeCounts>({ total: 0 });
|
|
|
|
async function fetchCounts() {
|
|
try {
|
|
const p = new URLSearchParams();
|
|
if (activeTag.value) p.set("tags", activeTag.value);
|
|
typeCounts.value = await apiGet<KnowledgeCounts>(`/api/knowledge/counts?${p}`);
|
|
} catch { /* silent */ }
|
|
}
|
|
|
|
// ─── New note dropdown ────────────────────────────────────────────────────────
|
|
|
|
const newNoteMenuOpen = ref(false);
|
|
|
|
function createNew(type: string) {
|
|
newNoteMenuOpen.value = false;
|
|
if (type === "task") {
|
|
router.push("/tasks/new");
|
|
} else {
|
|
router.push(type === "note" ? "/notes/new" : `/notes/new?type=${type}`);
|
|
}
|
|
}
|
|
|
|
function onClickOutsideNewNote(e: MouseEvent) {
|
|
const wrap = document.querySelector('.new-note-wrap');
|
|
if (wrap && !wrap.contains(e.target as Node)) {
|
|
newNoteMenuOpen.value = false;
|
|
}
|
|
}
|
|
|
|
// ─── Two-tier pagination ──────────────────────────────────────────────────────
|
|
|
|
const ID_BATCH = 100; // IDs fetched per server round-trip
|
|
const INITIAL_CONTENT = 50; // items loaded on first render
|
|
const CONTENT_PAGE = 24; // items loaded per sentinel trigger
|
|
const REFILL_THRESHOLD = 48; // fetch more IDs when queue drops below this
|
|
|
|
const items = ref<KnowledgeItem[]>([]);
|
|
const allTags = ref<string[]>([]);
|
|
const idQueue = ref<number[]>([]); // unloaded IDs ready to be content-fetched
|
|
const idOffset = ref(0); // next offset for ID batch requests
|
|
const totalKnowledge = ref(0); // total matching items on server
|
|
const allIdsFetched = ref(false); // no more ID pages to fetch
|
|
const idsFetching = ref(false); // ID batch request in-flight
|
|
const contentFetching = ref(false); // content batch request in-flight
|
|
const sentinelVisible = ref(false); // updated by IntersectionObserver
|
|
|
|
const loading = computed(() => idsFetching.value || contentFetching.value);
|
|
|
|
let fetchGen = 0; // incremented on each reset to invalidate stale responses
|
|
|
|
function buildFilterParams(): URLSearchParams {
|
|
const p = new URLSearchParams();
|
|
if (activeType.value) p.set("type", activeType.value);
|
|
if (activeTag.value) p.set("tags", activeTag.value);
|
|
p.set("sort", sortMode.value);
|
|
if (searchQuery.value.trim()) p.set("q", searchQuery.value.trim());
|
|
return p;
|
|
}
|
|
|
|
async function fetchIdBatch(gen: number) {
|
|
if (idsFetching.value || allIdsFetched.value) return;
|
|
idsFetching.value = true;
|
|
try {
|
|
const p = buildFilterParams();
|
|
p.set("limit", String(ID_BATCH));
|
|
p.set("offset", String(idOffset.value));
|
|
const data = await apiGet<{ ids: number[]; total: number; has_more: boolean }>(
|
|
`/api/knowledge/ids?${p}`
|
|
);
|
|
if (gen !== fetchGen) return;
|
|
idQueue.value.push(...data.ids);
|
|
idOffset.value += data.ids.length;
|
|
totalKnowledge.value = data.total;
|
|
if (!data.has_more) allIdsFetched.value = true;
|
|
} catch { /* silent */ }
|
|
finally { if (gen === fetchGen) idsFetching.value = false; }
|
|
}
|
|
|
|
async function loadNextContent(gen: number, count: number) {
|
|
if (contentFetching.value || idQueue.value.length === 0) return;
|
|
const toLoad = idQueue.value.splice(0, count); // claim IDs immediately
|
|
contentFetching.value = true;
|
|
try {
|
|
const data = await apiGet<{ items: KnowledgeItem[] }>(
|
|
`/api/knowledge/batch?ids=${toLoad.join(",")}`
|
|
);
|
|
if (gen !== fetchGen) return;
|
|
items.value.push(...data.items);
|
|
// Proactively refill ID queue before it runs out
|
|
if (idQueue.value.length < REFILL_THRESHOLD && !allIdsFetched.value && !idsFetching.value) {
|
|
fetchIdBatch(gen);
|
|
}
|
|
} catch { /* silent */ }
|
|
finally {
|
|
if (gen !== fetchGen) return;
|
|
contentFetching.value = false;
|
|
// Sentinel may still be visible if new items didn't push it off screen
|
|
await nextTick();
|
|
if (sentinelVisible.value && idQueue.value.length > 0 && gen === fetchGen) {
|
|
loadNextContent(gen, CONTENT_PAGE);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function reset() {
|
|
fetchGen++;
|
|
const gen = fetchGen;
|
|
items.value = [];
|
|
idQueue.value = [];
|
|
idOffset.value = 0;
|
|
totalKnowledge.value = 0;
|
|
allIdsFetched.value = false;
|
|
await fetchIdBatch(gen);
|
|
if (gen !== fetchGen) return;
|
|
await loadNextContent(gen, INITIAL_CONTENT);
|
|
}
|
|
|
|
async function fetchTags() {
|
|
try {
|
|
const data = await apiGet<{ tags: string[] }>("/api/knowledge/tags");
|
|
allTags.value = data.tags;
|
|
} catch { /* silent */ }
|
|
}
|
|
|
|
async function resetAndReobserve() {
|
|
await reset();
|
|
await nextTick();
|
|
setupObserver();
|
|
}
|
|
|
|
function onSearchInput() {
|
|
if (searchDebounce) clearTimeout(searchDebounce);
|
|
searchDebounce = setTimeout(() => resetAndReobserve(), 380);
|
|
}
|
|
|
|
watch([activeType, sortMode], () => resetAndReobserve());
|
|
// Closing the sweep remounts the feed, and with it the scroll sentinel — a
|
|
// fresh element the old observer is not watching. Without this the list loads
|
|
// its first page and then never loads another.
|
|
watch(sweepActive, (open) => { if (!open) resetAndReobserve(); });
|
|
watch(activeTag, () => { fetchCounts(); resetAndReobserve(); });
|
|
|
|
// ─── Today bar ────────────────────────────────────────────────────────────────
|
|
|
|
const overdueCount = ref(0);
|
|
|
|
async function fetchTodayBar() {
|
|
try {
|
|
const taskData = await apiGet<{ total: number }>(
|
|
`/api/tasks?status=todo&status=in_progress&overdue=true&limit=1`
|
|
).catch(() => ({ total: 0 }));
|
|
overdueCount.value = taskData.total ?? 0;
|
|
} catch { /* silent */ }
|
|
}
|
|
|
|
// ─── Graph panel ──────────────────────────────────────────────────────────────
|
|
|
|
const _GRAPH_OPEN_KEY = 'fa_knowledge_graph_open'
|
|
const _GRAPH_EXP_KEY = 'fa_knowledge_graph_expanded'
|
|
|
|
const graphOpen = ref(localStorage.getItem(_GRAPH_OPEN_KEY) === 'true')
|
|
const graphExpanded = ref(localStorage.getItem(_GRAPH_EXP_KEY) === 'true')
|
|
|
|
function toggleGraph() {
|
|
graphOpen.value = !graphOpen.value
|
|
localStorage.setItem(_GRAPH_OPEN_KEY, String(graphOpen.value))
|
|
}
|
|
|
|
function toggleGraphExpand() {
|
|
graphExpanded.value = !graphExpanded.value
|
|
localStorage.setItem(_GRAPH_EXP_KEY, String(graphExpanded.value))
|
|
}
|
|
|
|
// ─── Navigation helpers ───────────────────────────────────────────────────────
|
|
|
|
function isOverdue(item: KnowledgeItem): boolean {
|
|
if (!item.due_date || item.status === 'done' || item.status === 'cancelled') return false;
|
|
return new Date(item.due_date) < new Date(new Date().toDateString());
|
|
}
|
|
|
|
// Each record kind opens in ITS OWN editor. A snippet used to fall through to
|
|
// /notes/:id, whose save is a plain PATCH of the body — which left the snippet's
|
|
// derived `data` mirror describing the previous version (#3128). The service now
|
|
// recomposes the mirror either way, so this is no longer the guard; it is simply
|
|
// that the note editor cannot edit a snippet's signature, language or locations,
|
|
// and offering it as the way in was always wrong. Processes stay here on
|
|
// purpose: they have no editor of their own and the note editor knows the type.
|
|
function openItem(item: KnowledgeItem) {
|
|
if (item.note_type === 'task') {
|
|
router.push(`/tasks/${item.id}`);
|
|
} else if (item.note_type === 'snippet') {
|
|
router.push(`/snippets/${item.id}`);
|
|
} else {
|
|
router.push(`/notes/${item.id}`);
|
|
}
|
|
}
|
|
|
|
function formatDate(iso: string): string {
|
|
const d = new Date(iso);
|
|
const now = new Date();
|
|
const diff = now.getTime() - d.getTime();
|
|
const days = Math.floor(diff / 86_400_000);
|
|
if (days === 0) return "Today";
|
|
if (days === 1) return "Yesterday";
|
|
if (days < 7) return `${days}d ago`;
|
|
if (d.getFullYear() === now.getFullYear()) return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
|
}
|
|
|
|
// ─── Sentinel observer ────────────────────────────────────────────────────────
|
|
|
|
const sentinelEl = ref<HTMLElement | null>(null);
|
|
let observer: IntersectionObserver | null = null;
|
|
|
|
function setupObserver() {
|
|
observer?.disconnect();
|
|
if (!sentinelEl.value) return;
|
|
observer = new IntersectionObserver(
|
|
([entry]) => {
|
|
sentinelVisible.value = entry.isIntersecting;
|
|
if (entry.isIntersecting) loadNextContent(fetchGen, CONTENT_PAGE);
|
|
},
|
|
{ root: null, rootMargin: "200px" }
|
|
);
|
|
observer.observe(sentinelEl.value);
|
|
}
|
|
|
|
// ─── Lifecycle ────────────────────────────────────────────────────────────────
|
|
|
|
onMounted(async () => {
|
|
document.addEventListener('click', onClickOutsideNewNote);
|
|
await reset();
|
|
fetchTags();
|
|
fetchCounts();
|
|
fetchTodayBar();
|
|
await nextTick();
|
|
setupObserver();
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('click', onClickOutsideNewNote);
|
|
if (searchDebounce) clearTimeout(searchDebounce);
|
|
observer?.disconnect();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="knowledge-root" :class="{ 'graph-open': graphOpen, 'graph-expanded': graphExpanded && graphOpen }">
|
|
|
|
<!-- Today bar -->
|
|
<div v-if="overdueCount > 0" class="today-bar">
|
|
<div class="today-actions">
|
|
<router-link to="/tasks" class="overdue-badge">
|
|
{{ overdueCount }} overdue
|
|
</router-link>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main layout -->
|
|
<div class="knowledge-layout">
|
|
|
|
<!-- Filter panel -->
|
|
<aside class="filter-panel">
|
|
<!-- New note button -->
|
|
<div class="new-note-wrap">
|
|
<button class="btn-new-note" @click="newNoteMenuOpen = !newNoteMenuOpen">
|
|
<span class="btn-new-icon">+</span> New
|
|
</button>
|
|
<div v-if="newNoteMenuOpen" class="new-note-menu">
|
|
<button @click="createNew('note')">
|
|
<FileText :size="16" />
|
|
Note
|
|
</button>
|
|
<button @click="createNew('task')">
|
|
<CheckSquare :size="16" />
|
|
Task
|
|
</button>
|
|
<button @click="createNew('process')">
|
|
<Workflow :size="16" />
|
|
Process
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="filter-section">
|
|
<div class="filter-label">Type</div>
|
|
<button
|
|
class="filter-btn"
|
|
:class="{ active: activeType === '' }"
|
|
@click="activeType = ''"
|
|
>
|
|
<span class="filter-btn-label">All</span>
|
|
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
|
|
</button>
|
|
<button
|
|
v-for="[val, label] in FACET_CHIPS"
|
|
:key="val"
|
|
class="filter-btn"
|
|
:class="{ active: activeType === val }"
|
|
@click="activeType = val"
|
|
>
|
|
<span class="filter-btn-label">{{ label }}</span>
|
|
<span v-if="(typeCounts[val] ?? 0) > 1" class="filter-count">{{ typeCounts[val] }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="allTags.length > 0" class="filter-section">
|
|
<div class="filter-label">Tags</div>
|
|
<button
|
|
class="filter-btn filter-tag"
|
|
:class="{ active: activeTag === '' }"
|
|
@click="activeTag = ''"
|
|
>All tags</button>
|
|
<button
|
|
v-for="tag in allTags"
|
|
:key="tag"
|
|
class="filter-btn filter-tag"
|
|
:class="{ active: activeTag === tag }"
|
|
@click="activeTag = activeTag === tag ? '' : tag"
|
|
>{{ tag }}</button>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- Content area -->
|
|
<div class="knowledge-content">
|
|
|
|
<!-- Toolbar -->
|
|
<div class="knowledge-toolbar">
|
|
<div class="search-wrap">
|
|
<Search class="search-icon" :size="16" />
|
|
<input
|
|
v-model="searchQuery"
|
|
@input="onSearchInput"
|
|
type="text"
|
|
class="search-input"
|
|
placeholder="Search knowledge…"
|
|
/>
|
|
</div>
|
|
<select v-model="sortMode" class="sort-select">
|
|
<option value="modified">Recently modified</option>
|
|
<option value="created">Recently created</option>
|
|
<option value="alpha">Alphabetical</option>
|
|
<option value="type">By type</option>
|
|
</select>
|
|
<button class="btn-ghost btn-compact" :class="{ active: graphOpen }" @click="toggleGraph" title="Toggle graph view">
|
|
<Share2 :size="16" />
|
|
Graph
|
|
</button>
|
|
<button
|
|
class="btn-ghost btn-compact"
|
|
:class="{ active: sweepActive }"
|
|
title="Notes that assert a fact about something outside your control, least-recently-confirmed first. Most notes are decisions and never appear."
|
|
@click="sweepActive = !sweepActive"
|
|
>
|
|
<ShieldCheck :size="16" />
|
|
Due
|
|
</button>
|
|
<button
|
|
class="btn-ghost btn-compact"
|
|
:disabled="dupLoading"
|
|
title="Find notes or tasks already recorded that closely resemble each other — the report proposes, it never changes anything"
|
|
@click="loadDuplicates"
|
|
>
|
|
{{ dupLoading ? "Checking…" : "Find duplicates" }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- The sweep replaces the feed. It is not a facet: a facet answers
|
|
"show me this kind", and this answers "show me what nobody has
|
|
confirmed" — a question the type chips cannot narrow without
|
|
under-reporting it. -->
|
|
<NoteSweepPane v-if="sweepActive" @open-note="(id) => router.push(`/notes/${id}`)" />
|
|
|
|
<!-- Near-duplicate report. A proposal surface only: unlike snippets
|
|
(which merge losslessly), notes are never merged — the right fix is
|
|
supersession, extraction into a reference note, or leaving parallel
|
|
records alone, and choosing needs the records READ. That reading is
|
|
the assistant's job; this panel shows the human what exists. -->
|
|
<div v-if="dupChecked && !dupLoading" class="dup-panel">
|
|
<p v-if="!dupGroups.length" class="dup-empty">
|
|
No near-duplicate {{ dupKind }}s found — nothing recorded resembles
|
|
anything else closely enough to flag.
|
|
</p>
|
|
<template v-else>
|
|
<p class="dup-head">
|
|
{{ dupGroups.length }} possible duplicate
|
|
{{ dupGroups.length > 1 ? "sets" : "set" }} among your
|
|
{{ dupKind }}s. {{ dupSuggestion }}
|
|
</p>
|
|
<div v-for="(g, i) in dupGroups" :key="i" class="dup-group">
|
|
<div class="dup-members">
|
|
<router-link
|
|
v-for="m in g.members"
|
|
:key="m.id"
|
|
class="dup-member"
|
|
:to="dupKind === 'task' ? `/tasks/${m.id}` : `/notes/${m.id}`"
|
|
>
|
|
#{{ m.id }} {{ m.title }}
|
|
</router-link>
|
|
</div>
|
|
<span class="dup-score">{{ Math.round(g.top_score * 100) }}% alike</span>
|
|
<span
|
|
v-if="g.existing_supersessions?.length"
|
|
class="dup-claimed"
|
|
title="A supersession has already been declared inside this set — it is not an open question"
|
|
>already ruled on</span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- The whole feed stands down while the sweep is open: two answers
|
|
to two different questions on one screen is neither. Wrapped
|
|
rather than given an extra v-if branch, because the scroll
|
|
sentinel lives inside the grid and the observer must not be left
|
|
holding a ref to something that never renders. -->
|
|
<template v-if="!sweepActive">
|
|
<!-- Loading / empty -->
|
|
<div v-if="loading && items.length === 0" class="knowledge-empty">Loading…</div>
|
|
<div v-else-if="!loading && items.length === 0" class="knowledge-empty">
|
|
<p v-if="activeType || activeTag || searchQuery" class="empty-hint">No matches. Try clearing the filters.</p>
|
|
<p v-else class="empty-narrator">Your story is unwritten. Create your first note to begin.</p>
|
|
</div>
|
|
|
|
<!-- Card grid -->
|
|
<div v-else class="card-grid">
|
|
<div
|
|
v-for="item in items"
|
|
:key="item.id"
|
|
class="k-card"
|
|
:class="`k-card--${item.note_type}`"
|
|
@click="openItem(item)"
|
|
>
|
|
<!-- Type badge -->
|
|
<span class="type-badge" :class="item.task_kind === 'plan' ? 'badge--plan' : `badge--${item.note_type}`">
|
|
<span v-if="item.note_type === 'note'">Note</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 === 'snippet'">Snippet</span>
|
|
</span>
|
|
<!-- Kind sits BESIDE the type badge, not inside it: the type badge
|
|
speaks the vocabulary of this view's type filter (note / task /
|
|
plan / process), and kind is the other axis. `plan` is passed
|
|
as null because the badge to the left already says it — two
|
|
chips reading "Plan" would look like two facts. -->
|
|
<KindBadge :kind="item.task_kind === 'plan' ? null : item.task_kind" />
|
|
|
|
<div class="k-card-body">
|
|
<div class="k-card-title">{{ item.title }}</div>
|
|
|
|
<!-- Task specifics -->
|
|
<div v-if="item.note_type === 'task'" class="k-card-task">
|
|
<div class="task-badges">
|
|
<StatusBadge v-if="item.status" :status="item.status as TaskStatus" compact />
|
|
<PriorityBadge
|
|
v-if="item.priority && item.priority !== 'none'"
|
|
:priority="item.priority as TaskPriority"
|
|
compact
|
|
/>
|
|
</div>
|
|
<span
|
|
v-if="item.due_date"
|
|
class="task-due"
|
|
:class="{ 'task-overdue': isOverdue(item) }"
|
|
>{{ formatDate(item.due_date) }}</span>
|
|
<p v-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
|
|
</div>
|
|
|
|
<!-- Note snippet -->
|
|
<p v-else-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
|
|
</div>
|
|
|
|
<div class="k-card-footer">
|
|
<div class="k-card-tags">
|
|
<span v-for="tag in item.tags.slice(0, 3)" :key="tag" class="tag-pill">{{ tag }}</span>
|
|
</div>
|
|
<span
|
|
v-if="item.shared"
|
|
class="shared-tag"
|
|
:title="`Shared by ${item.owner ?? 'another user'} — their record, not yours`"
|
|
>by {{ item.owner ?? "another user" }}</span>
|
|
<span class="k-card-date">{{ formatDate(item.updated_at) }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sentinel — IntersectionObserver triggers next content batch -->
|
|
<div ref="sentinelEl" class="scroll-sentinel">
|
|
<span v-if="contentFetching" class="sentinel-loading">Loading…</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Graph panel -->
|
|
<aside v-if="graphOpen" class="graph-panel" :class="{ expanded: graphExpanded }">
|
|
<div class="graph-panel-header">
|
|
<span>Graph</span>
|
|
<div style="display:flex;gap:4px;align-items:center">
|
|
<button
|
|
class="btn-text"
|
|
@click="toggleGraphExpand"
|
|
:title="graphExpanded ? 'Narrow panel' : 'Expand panel'"
|
|
>
|
|
<ChevronLeft v-if="graphExpanded" :size="16" />
|
|
<ChevronRight v-else :size="16" />
|
|
</button>
|
|
<button class="btn-text" @click="toggleGraph" title="Close graph">
|
|
<X :size="16" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="graph-embed">
|
|
<GraphView :embedded="true" />
|
|
</div>
|
|
</aside>
|
|
|
|
</div><!-- end knowledge-layout -->
|
|
|
|
</div>
|
|
</template>
|
|
|
|
<style src="@/assets/dup-report.css" />
|
|
<style scoped>
|
|
/* ── Root layout ─────────────────────────────────────────── */
|
|
.knowledge-root {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: calc(100vh - var(--fs-layout-header));
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* ── Today bar ───────────────────────────────────────────── */
|
|
.today-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
padding: 8px 20px;
|
|
background: var(--fs-surface-raised);
|
|
border-bottom: 1px solid var(--fs-border-color);
|
|
flex-shrink: 0;
|
|
font-size: 0.82rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.today-actions { display: flex; align-items: center; gap: 10px; }
|
|
.overdue-badge {
|
|
padding: 2px 9px;
|
|
border-radius: 12px;
|
|
background: rgba(239,68,68,0.12);
|
|
border: 1px solid rgba(239,68,68,0.25);
|
|
color: #f87171;
|
|
text-decoration: none;
|
|
font-size: 0.78rem;
|
|
}
|
|
|
|
/* ── Main layout ─────────────────────────────────────────── */
|
|
.knowledge-layout {
|
|
display: flex;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
min-height: 0;
|
|
}
|
|
|
|
/* ── Filter panel ────────────────────────────────────────── */
|
|
.filter-panel {
|
|
width: var(--fs-layout-sidebar);
|
|
flex-shrink: 0;
|
|
padding: 16px 12px;
|
|
border-right: 1px solid var(--fs-border-color);
|
|
overflow-y: auto;
|
|
background: var(--fs-surface-raised);
|
|
}
|
|
.filter-section { margin-bottom: 20px; }
|
|
.filter-section + .filter-section::before {
|
|
content: '· · ·';
|
|
display: block;
|
|
text-align: center;
|
|
color: color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
|
font-size: 0.9rem;
|
|
letter-spacing: 0.4em;
|
|
padding: 4px 0 12px;
|
|
}
|
|
.filter-label {
|
|
font-family: 'Fraunces', Georgia, serif;
|
|
font-size: 0.95rem;
|
|
color: var(--fs-accent);
|
|
margin-bottom: 8px;
|
|
padding: 0 4px;
|
|
}
|
|
/* ── New item button ─────────────────────────────────────── */
|
|
.new-note-wrap {
|
|
position: relative;
|
|
margin-bottom: 16px;
|
|
}
|
|
.btn-new-note {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 8px 12px;
|
|
border-radius: 10px;
|
|
border: none;
|
|
background: var(--fs-gradient-cta);
|
|
color: var(--fs-text-on-action);
|
|
cursor: pointer;
|
|
font-size: 0.85rem;
|
|
font-weight: 500;
|
|
transition: box-shadow 0.15s;
|
|
}
|
|
.btn-new-note:hover { box-shadow: var(--fs-glow-cta-hover); }
|
|
.btn-new-icon {
|
|
font-size: 1.1rem;
|
|
line-height: 1;
|
|
font-weight: 300;
|
|
}
|
|
.new-note-menu {
|
|
position: absolute;
|
|
top: calc(100% + 6px);
|
|
left: 0;
|
|
right: 0;
|
|
background: var(--fs-surface-raised);
|
|
border: 1px solid var(--fs-border-color);
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
z-index: 50;
|
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
|
|
padding: 4px 0;
|
|
}
|
|
.new-note-menu button {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
width: 100%;
|
|
padding: 9px 14px;
|
|
background: none;
|
|
border: none;
|
|
color: var(--fs-text-primary);
|
|
cursor: pointer;
|
|
font-size: 0.84rem;
|
|
text-align: left;
|
|
transition: background 0.12s, color 0.12s;
|
|
}
|
|
.new-note-menu button:hover {
|
|
background: var(--fs-accent-soft);
|
|
color: var(--fs-accent);
|
|
}
|
|
.new-note-menu button svg {
|
|
flex-shrink: 0;
|
|
opacity: 0.6;
|
|
}
|
|
.new-note-menu button:hover svg {
|
|
opacity: 1;
|
|
stroke: var(--fs-accent);
|
|
}
|
|
|
|
.filter-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
width: 100%;
|
|
text-align: left;
|
|
padding: 5px 8px;
|
|
border-radius: 7px;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--fs-text-primary);
|
|
cursor: pointer;
|
|
font-size: 0.85rem;
|
|
margin-bottom: 2px;
|
|
transition: background 0.12s, color 0.12s;
|
|
opacity: 0.75;
|
|
}
|
|
.filter-btn:hover { background: rgba(255,255,255,0.05); opacity: 1; }
|
|
.filter-btn.active {
|
|
background: var(--fs-accent-wash);
|
|
color: var(--fs-accent);
|
|
opacity: 1;
|
|
}
|
|
.filter-btn-label { flex: 1; }
|
|
.filter-count {
|
|
font-size: 0.7rem;
|
|
padding: 1px 6px;
|
|
border-radius: 10px;
|
|
background: rgba(255,255,255,0.07);
|
|
color: var(--fs-text-tertiary);
|
|
font-weight: 500;
|
|
min-width: 20px;
|
|
text-align: center;
|
|
flex-shrink: 0;
|
|
}
|
|
.filter-btn.active .filter-count {
|
|
background: color-mix(in srgb, var(--fs-accent) 20%, transparent);
|
|
color: var(--fs-accent-fg);
|
|
}
|
|
.filter-tag { font-size: 0.78rem; }
|
|
|
|
/* ── Content area ────────────────────────────────────────── */
|
|
.knowledge-content {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
min-width: 0;
|
|
}
|
|
|
|
/* ── Toolbar ─────────────────────────────────────────────── */
|
|
.knowledge-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 12px 20px;
|
|
flex-shrink: 0;
|
|
border-bottom: 1px solid var(--fs-border-color);
|
|
}
|
|
.search-wrap {
|
|
flex: 1;
|
|
position: relative;
|
|
}
|
|
.search-icon {
|
|
position: absolute;
|
|
left: 10px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
color: var(--fs-text-tertiary);
|
|
pointer-events: none;
|
|
}
|
|
.search-input {
|
|
width: 100%;
|
|
padding: 7px 12px 7px 32px;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--fs-border-color);
|
|
background: var(--fs-surface-hover);
|
|
color: var(--fs-text-primary);
|
|
font-size: 0.88rem;
|
|
outline: none;
|
|
transition: border-color 0.15s;
|
|
}
|
|
.search-input:focus { border-color: var(--fs-accent); }
|
|
.sort-select {
|
|
padding: 7px 10px;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--fs-border-color);
|
|
background: var(--fs-surface-hover);
|
|
color: var(--fs-text-primary);
|
|
font-size: 0.85rem;
|
|
cursor: pointer;
|
|
outline: none;
|
|
}
|
|
|
|
/* ── Card grid ───────────────────────────────────────────── */
|
|
.card-grid {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 16px 20px;
|
|
mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
|
|
-webkit-mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
|
gap: 12px;
|
|
align-content: start;
|
|
}
|
|
|
|
.k-card {
|
|
position: relative;
|
|
background: var(--fs-surface-hover);
|
|
border: 1px solid var(--fs-border-color);
|
|
border-radius: var(--fs-radius-xl);
|
|
padding: 14px;
|
|
cursor: pointer;
|
|
transition: border-color 0.15s, transform 0.12s, box-shadow 0.15s;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
min-height: 160px;
|
|
overflow: hidden;
|
|
}
|
|
.k-card:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 8px 28px color-mix(in srgb, var(--fs-accent) 25%, transparent), 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
border-color: color-mix(in srgb, var(--fs-accent) 35%, transparent);
|
|
}
|
|
|
|
/* Type-specific card DNA */
|
|
.k-card--note { border-color: color-mix(in srgb, var(--fs-accent) 20%, transparent); }
|
|
.k-card--task { border-color: rgba(212, 160, 23, 0.18); }
|
|
|
|
/* Top gradient bars */
|
|
.k-card--note::before,
|
|
.k-card--task::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
height: 3px;
|
|
border-radius: 14px 14px 0 0;
|
|
}
|
|
.k-card--note::before {
|
|
right: 0;
|
|
background: linear-gradient(90deg, var(--fs-accent), #7A6DA8);
|
|
}
|
|
.k-card--task::before {
|
|
right: 0;
|
|
background: linear-gradient(90deg, #d4a017, #fbbf24);
|
|
}
|
|
|
|
/* Type badge */
|
|
.type-badge {
|
|
position: absolute;
|
|
top: 10px;
|
|
right: 10px;
|
|
font-size: 0.68rem;
|
|
padding: 2px 7px;
|
|
border-radius: 10px;
|
|
font-weight: 500;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
.badge--note { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: #7A6DA8; }
|
|
.badge--task { background: rgba(212,160,23,0.15); color: #fbbf24; }
|
|
.badge--plan { background: rgba(99,102,241,0.18); color: #818cf8; }
|
|
/* Snippet and process are NEUTRAL on purpose. Both were unstyled — and the
|
|
snippet had no label either, so all 90 of them rendered an empty chip in
|
|
this feed (#3128). Giving them hues would put a third and fourth colour
|
|
beside KindBadge's warm/cool pair on the same card; a record type that
|
|
isn't an alarm reads better as plain. Standard body pair, so the contrast
|
|
is the one the palette already guarantees. */
|
|
.badge--snippet,
|
|
.badge--process { background: var(--fs-surface-raised); color: var(--fs-text-secondary); }
|
|
|
|
.k-card-body { flex: 1; padding-right: 40px; }
|
|
.k-card-title {
|
|
font-weight: 500;
|
|
font-size: 0.92rem;
|
|
margin-bottom: 5px;
|
|
line-height: 1.3;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
.k-card-snippet {
|
|
font-size: 0.8rem;
|
|
color: var(--fs-text-tertiary);
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 4;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
line-height: 1.45;
|
|
margin: 0;
|
|
}
|
|
.k-card-footer {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 6px;
|
|
}
|
|
.k-card-tags { display: flex; gap: 4px; flex-wrap: wrap; min-width: 0; }
|
|
.tag-pill {
|
|
font-size: 0.68rem;
|
|
padding: 1px 6px;
|
|
border-radius: 8px;
|
|
background: rgba(255,255,255,0.05);
|
|
color: var(--fs-text-tertiary);
|
|
}
|
|
.k-card-date { font-size: 0.72rem; color: var(--fs-text-secondary); white-space: nowrap; opacity: 0.7; }
|
|
/* Only rendered for a record another user owns, so an unmarked card is
|
|
unambiguously the viewer's own. */
|
|
.shared-tag {
|
|
font-size: 0.68rem;
|
|
padding: 0.08rem 0.35rem;
|
|
border-radius: 4px;
|
|
white-space: nowrap;
|
|
background: color-mix(in srgb, var(--fs-text-secondary) 15%, transparent);
|
|
color: var(--fs-text-secondary-fg);
|
|
}
|
|
|
|
/* ── Task card ──────────────────────────────────────────── */
|
|
.k-card-task {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
.task-badges {
|
|
display: flex;
|
|
gap: 5px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
|
|
.task-due {
|
|
font-size: 0.78rem;
|
|
color: var(--fs-text-secondary);
|
|
}
|
|
.task-overdue {
|
|
color: var(--fs-overdue);
|
|
font-weight: 500;
|
|
}
|
|
|
|
/* ── Empty / loading ─────────────────────────────────────── */
|
|
.knowledge-empty {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 60px 20px;
|
|
color: var(--fs-text-tertiary);
|
|
text-align: center;
|
|
gap: 6px;
|
|
}
|
|
.empty-hint { font-size: 0.85rem; opacity: 0.7; }
|
|
.empty-narrator {
|
|
font-family: 'Fraunces', Georgia, serif;
|
|
font-size: 1rem;
|
|
color: var(--fs-text-secondary);
|
|
opacity: 0.85;
|
|
}
|
|
|
|
/* ── Sentinel ────────────────────────────────────────────── */
|
|
.scroll-sentinel {
|
|
grid-column: 1 / -1;
|
|
height: 40px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.sentinel-loading {
|
|
font-size: 0.8rem;
|
|
color: var(--fs-text-tertiary);
|
|
}
|
|
|
|
/* ── Graph panel ─────────────────────────────────────────── */
|
|
.graph-panel {
|
|
width: 500px;
|
|
flex-shrink: 0;
|
|
border-left: 1px solid var(--fs-border-color);
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--fs-surface-raised);
|
|
transition: width 0.2s ease;
|
|
}
|
|
.graph-panel.expanded {
|
|
width: min(960px, 60vw);
|
|
}
|
|
.graph-panel-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 10px 14px;
|
|
font-size: 0.85rem;
|
|
font-weight: 500;
|
|
border-bottom: 1px solid var(--fs-border-color);
|
|
flex-shrink: 0;
|
|
}
|
|
/* RESTORED (#2444). The panel is a flex COLUMN and its header is
|
|
`flex-shrink: 0`, so this is the item that takes the remaining height — and
|
|
without it the `height: 100%` below resolves against `auto` and does
|
|
nothing, which made the comment underneath a spec for a rule that could not
|
|
work. `min-height: 0` is the companion that lets a flex item shrink under
|
|
its content instead of overflowing the panel. */
|
|
.graph-embed {
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
|
|
/* Override GraphView's 100vh height so it fills the panel instead */
|
|
.graph-embed :deep(.graph-page) {
|
|
height: 100%;
|
|
}
|
|
|
|
/* A set someone already ruled on — quiet, not celebratory: it means "skip". */
|
|
.dup-claimed {
|
|
font-size: 0.72rem;
|
|
color: var(--fs-text-tertiary);
|
|
border: 1px solid var(--fs-border-color);
|
|
border-radius: 4px;
|
|
padding: 0.05rem 0.4rem;
|
|
white-space: nowrap;
|
|
}
|
|
</style>
|