feat(ui): declutter dashboard done-recently + MCP-access, add per-project stats
Dashboard: - 'Done recently' chip-cloud -> compact uniform list (Active-now row style), showing 5 with inline expand to the rest (backend already returns up to 8). - New 'Projects' rail card: each active project with 'N open · M done'. Backend already computed done_count (dashboard.py) — now surfaced in the /api/dashboard payload per active project. MCP Access (Connect Claude / Claude Code): - Progressive disclosure: lead with the pre-filled plugin-install snippet; fold server name, scope, marketplace URL, and the MCP-only path into a single 'Customize' expander. Desktop tab keeps its own server-name field. - Marketplace URL now defaults to this instance's own repo via config.PLUGIN_MARKETPLACE_URL (env-overridable); /api/plugin/marketplace-url falls back to it, so the field + install snippet are pre-filled out of the box instead of showing a generic placeholder. Refs #761 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ interface TaskRow { id: number; title: string; status: string; priority: string
|
|||||||
interface MilestoneBlock { id: number; title: string; progress_pct: number; open_tasks: TaskRow[] }
|
interface MilestoneBlock { id: number; title: string; progress_pct: number; open_tasks: TaskRow[] }
|
||||||
interface ActiveProject {
|
interface ActiveProject {
|
||||||
id: number; title: string; color: string | null; last_activity: string;
|
id: number; title: string; color: string | null; last_activity: string;
|
||||||
open_count: number; progress_pct: number;
|
open_count: number; done_count: number; progress_pct: number;
|
||||||
milestones: MilestoneBlock[]; no_milestone: TaskRow[];
|
milestones: MilestoneBlock[]; no_milestone: TaskRow[];
|
||||||
}
|
}
|
||||||
interface DoneItem { id: number; title: string; project_title: string | null; completed_at: string }
|
interface DoneItem { id: number; title: string; project_title: string | null; completed_at: string }
|
||||||
@@ -22,6 +22,8 @@ interface DashboardData {
|
|||||||
|
|
||||||
const data = ref<DashboardData | null>(null);
|
const data = ref<DashboardData | null>(null);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
const showAllDone = ref(false);
|
||||||
|
const DONE_VISIBLE = 5;
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -53,17 +55,25 @@ function fmtEvent(e: UpcomingEvent): string {
|
|||||||
|
|
||||||
<template v-else-if="data">
|
<template v-else-if="data">
|
||||||
<!-- Done recently -->
|
<!-- Done recently -->
|
||||||
<section v-if="data.recently_completed.length" class="done-strip">
|
<section v-if="data.recently_completed.length" class="done-recent">
|
||||||
<span class="dash-label">✓ Done recently</span>
|
<div class="dash-label">✓ Done recently</div>
|
||||||
<router-link
|
<router-link
|
||||||
v-for="d in data.recently_completed"
|
v-for="d in (showAllDone ? data.recently_completed : data.recently_completed.slice(0, DONE_VISIBLE))"
|
||||||
:key="d.id"
|
:key="d.id"
|
||||||
:to="`/tasks/${d.id}`"
|
:to="`/tasks/${d.id}`"
|
||||||
class="done-chip"
|
class="done-row"
|
||||||
>
|
>
|
||||||
{{ d.title }}
|
<span class="done-mark">○</span>
|
||||||
|
<span class="done-title">{{ d.title }}</span>
|
||||||
<span class="done-meta">{{ d.project_title || "—" }} · {{ relativeTime(d.completed_at) }}</span>
|
<span class="done-meta">{{ d.project_title || "—" }} · {{ relativeTime(d.completed_at) }}</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<button
|
||||||
|
v-if="data.recently_completed.length > DONE_VISIBLE"
|
||||||
|
class="done-more"
|
||||||
|
@click="showAllDone = !showAllDone"
|
||||||
|
>
|
||||||
|
{{ showAllDone ? "Show less" : `… ${data.recently_completed.length - DONE_VISIBLE} more` }}
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="dash-cols">
|
<div class="dash-cols">
|
||||||
@@ -140,6 +150,22 @@ function fmtEvent(e: UpcomingEvent): string {
|
|||||||
<span class="stats-sub">{{ data.week_stats.in_progress }} in progress · {{ data.week_stats.active_plans }} plans</span>
|
<span class="stats-sub">{{ data.week_stats.in_progress }} in progress · {{ data.week_stats.active_plans }} plans</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template v-if="data.active_projects.length">
|
||||||
|
<div class="dash-label">Projects</div>
|
||||||
|
<div class="rail-card proj-stats">
|
||||||
|
<router-link
|
||||||
|
v-for="p in data.active_projects"
|
||||||
|
:key="p.id"
|
||||||
|
:to="`/projects/${p.id}`"
|
||||||
|
class="pstat-row"
|
||||||
|
>
|
||||||
|
<span class="pstat-dot" :style="{ background: p.color || 'var(--color-primary)' }" />
|
||||||
|
<span class="pstat-title">{{ p.title }}</span>
|
||||||
|
<span class="pstat-counts">{{ p.open_count }} open · {{ p.done_count }} done</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="quick-add">
|
<div class="quick-add">
|
||||||
<router-link to="/tasks/new" class="qa-btn">+ Task</router-link>
|
<router-link to="/tasks/new" class="qa-btn">+ Task</router-link>
|
||||||
<router-link to="/notes/new" class="qa-btn">+ Note</router-link>
|
<router-link to="/notes/new" class="qa-btn">+ Note</router-link>
|
||||||
@@ -159,10 +185,14 @@ function fmtEvent(e: UpcomingEvent): string {
|
|||||||
.dash-empty { color: var(--color-muted); padding: 1rem 0; }
|
.dash-empty { color: var(--color-muted); padding: 1rem 0; }
|
||||||
.dash-empty.card { padding: 1rem; border: 1px dashed var(--color-border); border-radius: 10px; }
|
.dash-empty.card { padding: 1rem; border: 1px dashed var(--color-border); border-radius: 10px; }
|
||||||
|
|
||||||
.done-strip { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1.5rem; }
|
.done-recent { margin-bottom: 1.5rem; }
|
||||||
.done-chip { display: inline-flex; flex-direction: column; gap: 1px; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 14px; padding: 4px 12px; font-size: 0.82rem; color: var(--color-text); text-decoration: none; }
|
.done-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.55rem; border-radius: 7px; text-decoration: none; color: var(--color-text); font-size: 0.86rem; }
|
||||||
.done-chip:hover { border-color: var(--color-primary); }
|
.done-row:hover { background: var(--color-hover); }
|
||||||
.done-meta { font-size: 0.7rem; color: var(--color-muted); }
|
.done-mark { color: var(--color-primary); flex-shrink: 0; }
|
||||||
|
.done-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.done-meta { font-size: 0.72rem; color: var(--color-muted); white-space: nowrap; flex-shrink: 0; }
|
||||||
|
.done-more { background: none; border: none; cursor: pointer; margin-top: 0.25rem; padding: 0.2rem 0.55rem; font-size: 0.78rem; color: var(--color-primary); font-family: inherit; }
|
||||||
|
.done-more:hover { text-decoration: underline; }
|
||||||
|
|
||||||
.dash-cols { display: flex; gap: 1.25rem; align-items: flex-start; }
|
.dash-cols { display: flex; gap: 1.25rem; align-items: flex-start; }
|
||||||
.dash-main { flex: 1.7; min-width: 0; }
|
.dash-main { flex: 1.7; min-width: 0; }
|
||||||
@@ -200,6 +230,12 @@ function fmtEvent(e: UpcomingEvent): string {
|
|||||||
.rail-empty { margin: 0; color: var(--color-muted); font-size: 0.85rem; }
|
.rail-empty { margin: 0; color: var(--color-muted); font-size: 0.85rem; }
|
||||||
.stats { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.9rem; }
|
.stats { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.9rem; }
|
||||||
.stats-sub { color: var(--color-muted); font-size: 0.78rem; }
|
.stats-sub { color: var(--color-muted); font-size: 0.78rem; }
|
||||||
|
.proj-stats { display: flex; flex-direction: column; gap: 0.1rem; padding: 0.4rem 0.45rem; }
|
||||||
|
.pstat-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.4rem; border-radius: 7px; text-decoration: none; color: var(--color-text); font-size: 0.85rem; }
|
||||||
|
.pstat-row:hover { background: var(--color-hover); }
|
||||||
|
.pstat-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.pstat-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
|
||||||
|
.pstat-counts { font-size: 0.74rem; color: var(--color-muted); white-space: nowrap; flex-shrink: 0; }
|
||||||
.quick-add { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
.quick-add { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||||
.qa-btn { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 8px; padding: 6px 12px; font-size: 0.82rem; color: var(--color-text); text-decoration: none; }
|
.qa-btn { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 8px; padding: 6px 12px; font-size: 0.82rem; color: var(--color-text); text-decoration: none; }
|
||||||
.qa-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
.qa-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||||
|
|||||||
@@ -1550,35 +1550,6 @@ function formatUserDate(iso: string): string {
|
|||||||
token value, or copy the snippet now and paste your own where it says <code><your-token></code>.
|
token value, or copy the snippet now and paste your own where it says <code><your-token></code>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Per-client config: server name + scope (persisted to localStorage) -->
|
|
||||||
<div class="mcp-client-config">
|
|
||||||
<label class="mcp-config-field">
|
|
||||||
<span class="mcp-config-label">Server name</span>
|
|
||||||
<input
|
|
||||||
v-model="mcpServerName"
|
|
||||||
type="text"
|
|
||||||
placeholder="scribe"
|
|
||||||
class="settings-input"
|
|
||||||
spellcheck="false"
|
|
||||||
/>
|
|
||||||
<span class="mcp-hint">
|
|
||||||
Local label Claude uses for this server. Examples: <code>scribe</code>, <code>scribe-dev</code>.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label class="mcp-config-field">
|
|
||||||
<span class="mcp-config-label">Scope</span>
|
|
||||||
<select v-model="mcpScope" class="settings-input">
|
|
||||||
<option value="user">user — available across all projects</option>
|
|
||||||
<option value="project">project — write to current repo's .mcp.json</option>
|
|
||||||
<option value="local">local — this machine + repo only</option>
|
|
||||||
</select>
|
|
||||||
<span class="mcp-hint">
|
|
||||||
Where Claude Code stores the server registration. Pick <code>project</code> to commit it
|
|
||||||
to a specific repo's <code>.mcp.json</code>.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mcp-client-tabs" role="tablist">
|
<div class="mcp-client-tabs" role="tablist">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1602,19 +1573,6 @@ function formatUserDate(iso: string): string {
|
|||||||
<strong>Recommended — install the Scribe plugin.</strong> One install wires up the MCP
|
<strong>Recommended — install the Scribe plugin.</strong> One install wires up the MCP
|
||||||
connection, a session-start hook that surfaces your rules, and the Scribe process-skills.
|
connection, a session-start hook that surfaces your rules, and the Scribe process-skills.
|
||||||
</p>
|
</p>
|
||||||
<label class="mcp-config-field">
|
|
||||||
<span class="mcp-config-label">Scribe plugin marketplace (git URL)</span>
|
|
||||||
<input
|
|
||||||
v-model="pluginMarketplaceUrl"
|
|
||||||
type="text"
|
|
||||||
placeholder="https://git.example.com/you/Scribe.git"
|
|
||||||
class="settings-input"
|
|
||||||
spellcheck="false"
|
|
||||||
/>
|
|
||||||
<span class="mcp-hint">
|
|
||||||
The Scribe app's own git repo — it ships the plugin in lockstep with this instance.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<ol>
|
<ol>
|
||||||
<li>
|
<li>
|
||||||
Add the marketplace and install the plugin:
|
Add the marketplace and install the plugin:
|
||||||
@@ -1637,42 +1595,81 @@ function formatUserDate(iso: string): string {
|
|||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<details class="mcp-advanced">
|
<details class="mcp-advanced">
|
||||||
<summary>Advanced: connect the MCP only (no plugin)</summary>
|
<summary>Customize</summary>
|
||||||
<ol>
|
|
||||||
<li>
|
<label class="mcp-config-field">
|
||||||
Register just the MCP server:
|
<span class="mcp-config-label">Plugin marketplace (git URL)</span>
|
||||||
<div class="mcp-code-row">
|
<input
|
||||||
<pre class="mcp-code">{{ claudeCodeCommand }}</pre>
|
v-model="pluginMarketplaceUrl"
|
||||||
<button class="btn btn-secondary btn-sm" @click="copySnippet(claudeCodeCommand, 'cc-add')">
|
type="text"
|
||||||
{{ copiedSnippetKey === 'cc-add' ? 'Copied' : 'Copy' }}
|
:placeholder="serverMarketplaceUrl || 'https://git.example.com/you/Scribe.git'"
|
||||||
</button>
|
class="settings-input"
|
||||||
</div>
|
spellcheck="false"
|
||||||
</li>
|
/>
|
||||||
<li>
|
<span class="mcp-hint">
|
||||||
Verify with <code>/mcp</code> — <code>{{ effectiveMcpName }}</code> should appear as connected.
|
Pre-filled with this instance's repo. Override only if you host the plugin elsewhere.
|
||||||
You won't get the session-start rule push or the Scribe skills this way.
|
</span>
|
||||||
</li>
|
</label>
|
||||||
</ol>
|
|
||||||
|
<div class="mcp-client-config">
|
||||||
|
<label class="mcp-config-field">
|
||||||
|
<span class="mcp-config-label">Server name</span>
|
||||||
|
<input v-model="mcpServerName" type="text" placeholder="scribe" class="settings-input" spellcheck="false" />
|
||||||
|
<span class="mcp-hint">
|
||||||
|
Local label for the MCP-only path below. Examples: <code>scribe</code>, <code>scribe-dev</code>.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="mcp-config-field">
|
||||||
|
<span class="mcp-config-label">Scope</span>
|
||||||
|
<select v-model="mcpScope" class="settings-input">
|
||||||
|
<option value="user">user — available across all projects</option>
|
||||||
|
<option value="project">project — write to current repo's .mcp.json</option>
|
||||||
|
<option value="local">local — this machine + repo only</option>
|
||||||
|
</select>
|
||||||
|
<span class="mcp-hint">Where Claude Code stores the MCP-only registration.</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mcp-hint" style="margin-top: 0.75rem;">
|
||||||
|
<strong>Connect the MCP only (no plugin).</strong> You won't get the session-start rule
|
||||||
|
push or the Scribe skills this way.
|
||||||
|
</p>
|
||||||
|
<div class="mcp-code-row">
|
||||||
|
<pre class="mcp-code">{{ claudeCodeCommand }}</pre>
|
||||||
|
<button class="btn btn-secondary btn-sm" @click="copySnippet(claudeCodeCommand, 'cc-add')">
|
||||||
|
{{ copiedSnippetKey === 'cc-add' ? 'Copied' : 'Copy' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mcp-hint">
|
||||||
|
Verify with <code>/mcp</code> — <code>{{ effectiveMcpName }}</code> should appear as connected.
|
||||||
|
</p>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Claude Desktop tab -->
|
<!-- Claude Desktop tab -->
|
||||||
<ol v-else-if="mcpClientTab === 'claude-desktop'">
|
<div v-else-if="mcpClientTab === 'claude-desktop'">
|
||||||
<li>
|
<label class="mcp-config-field">
|
||||||
Add this block to your Claude Desktop MCP config file
|
<span class="mcp-config-label">Server name</span>
|
||||||
(<code>~/Library/Application Support/Claude/claude_desktop_config.json</code> on macOS,
|
<input v-model="mcpServerName" type="text" placeholder="scribe" class="settings-input" spellcheck="false" />
|
||||||
<code>%APPDATA%\Claude\claude_desktop_config.json</code> on Windows):
|
<span class="mcp-hint">The key used for this server in the config JSON below.</span>
|
||||||
<div class="mcp-code-row">
|
</label>
|
||||||
<pre class="mcp-code">{{ mcpConfigSnippet }}</pre>
|
<ol>
|
||||||
<button class="btn btn-secondary btn-sm" @click="copySnippet(mcpConfigSnippet, 'cd-config')">
|
<li>
|
||||||
{{ copiedSnippetKey === 'cd-config' ? 'Copied' : 'Copy' }}
|
Add this block to your Claude Desktop MCP config file
|
||||||
</button>
|
(<code>~/Library/Application Support/Claude/claude_desktop_config.json</code> on macOS,
|
||||||
</div>
|
<code>%APPDATA%\Claude\claude_desktop_config.json</code> on Windows):
|
||||||
</li>
|
<div class="mcp-code-row">
|
||||||
<li>
|
<pre class="mcp-code">{{ mcpConfigSnippet }}</pre>
|
||||||
Restart Claude Desktop. The Scribe tools should appear in the available tools list.
|
<button class="btn btn-secondary btn-sm" @click="copySnippet(mcpConfigSnippet, 'cd-config')">
|
||||||
</li>
|
{{ copiedSnippetKey === 'cd-config' ? 'Copied' : 'Copy' }}
|
||||||
</ol>
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Restart Claude Desktop. The Scribe tools should appear in the available tools list.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ class Config:
|
|||||||
BASE_URL: str = os.environ.get("BASE_URL", "http://localhost:5000").rstrip("/")
|
BASE_URL: str = os.environ.get("BASE_URL", "http://localhost:5000").rstrip("/")
|
||||||
TRUST_PROXY_HEADERS: bool = os.environ.get("TRUST_PROXY_HEADERS", "").lower() in ("1", "true", "yes")
|
TRUST_PROXY_HEADERS: bool = os.environ.get("TRUST_PROXY_HEADERS", "").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
# Git URL of the repo that ships this Scribe plugin. There is one correct
|
||||||
|
# value per deployment (the repo serving the plugin), so it's a config
|
||||||
|
# default — not something each user types. Surfaced in Settings → MCP Access
|
||||||
|
# as the default install-command marketplace; an admin can still override it
|
||||||
|
# per-instance via the Plugin marketplace setting.
|
||||||
|
PLUGIN_MARKETPLACE_URL: str = os.environ.get(
|
||||||
|
"PLUGIN_MARKETPLACE_URL",
|
||||||
|
"https://git.fabledsword.com/bvandeusen/FabledScribe.git",
|
||||||
|
)
|
||||||
|
|
||||||
# OIDC / OAuth2 SSO (e.g. Authentik)
|
# OIDC / OAuth2 SSO (e.g. Authentik)
|
||||||
OIDC_ISSUER: str = os.environ.get("OIDC_ISSUER", "")
|
OIDC_ISSUER: str = os.environ.get("OIDC_ISSUER", "")
|
||||||
OIDC_CLIENT_ID: str = os.environ.get("OIDC_CLIENT_ID", "")
|
OIDC_CLIENT_ID: str = os.environ.get("OIDC_CLIENT_ID", "")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|||||||
from quart import Blueprint, g, jsonify, request
|
from quart import Blueprint, g, jsonify, request
|
||||||
|
|
||||||
from scribe.auth import admin_required, get_current_user_id, login_required
|
from scribe.auth import admin_required, get_current_user_id, login_required
|
||||||
|
from scribe.config import Config
|
||||||
from scribe.services import plugin_context as plugin_ctx_svc
|
from scribe.services import plugin_context as plugin_ctx_svc
|
||||||
from scribe.services import repo_bindings as repo_bindings_svc
|
from scribe.services import repo_bindings as repo_bindings_svc
|
||||||
from scribe.services.settings import get_admin_setting, set_setting
|
from scribe.services.settings import get_admin_setting, set_setting
|
||||||
@@ -60,8 +61,10 @@ async def session_context():
|
|||||||
@login_required
|
@login_required
|
||||||
async def get_marketplace_url():
|
async def get_marketplace_url():
|
||||||
"""The plugin marketplace git URL, readable by any logged-in user so their
|
"""The plugin marketplace git URL, readable by any logged-in user so their
|
||||||
Settings page can show a copyable install command."""
|
Settings page can show a copyable install command. Falls back to the
|
||||||
return jsonify({"marketplace_url": await get_admin_setting(_MARKETPLACE_KEY)})
|
config default (this deployment's own repo) when no admin override is set."""
|
||||||
|
url = await get_admin_setting(_MARKETPLACE_KEY, default=Config.PLUGIN_MARKETPLACE_URL)
|
||||||
|
return jsonify({"marketplace_url": url})
|
||||||
|
|
||||||
|
|
||||||
@plugin_bp.put("/marketplace-url")
|
@plugin_bp.put("/marketplace-url")
|
||||||
|
|||||||
@@ -122,7 +122,8 @@ async def _active_projects(user_id: int) -> list[dict]:
|
|||||||
out.append({
|
out.append({
|
||||||
"id": project.id, "title": project.title, "color": project.color,
|
"id": project.id, "title": project.title, "color": project.color,
|
||||||
"last_activity": (last or project.updated_at).isoformat(),
|
"last_activity": (last or project.updated_at).isoformat(),
|
||||||
"open_count": open_count, "progress_pct": progress_pct,
|
"open_count": open_count, "done_count": done_count,
|
||||||
|
"progress_pct": progress_pct,
|
||||||
"milestones": milestones,
|
"milestones": milestones,
|
||||||
"no_milestone": [_task_row(t) for t in no_ms],
|
"no_milestone": [_task_row(t) for t in no_ms],
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user