tool card UI redesign: semantic icons, inline diff persistence, tool detail page, MCP-friendly titles
Nightly Build / build (push) Successful in 6m38s

This commit is contained in:
2026-07-21 23:39:41 +01:00
parent 8e891fbced
commit c11702c3d3
44 changed files with 1103 additions and 50 deletions
+55 -5
View File
@@ -11,6 +11,13 @@ pub struct LlmToolCall {
/// payload, e.g. MCP `structuredContent`). Drives frontend rendering.
pub result_type: String,
pub status: String,
/// For a file-write tool: the file content **before**/**after** the write,
/// captured at execution time so the diff renders inline in the chat card and
/// survives a page reload (it was previously only on the transient `PendingWrite`
/// event). `None` for non-write tools, an unreadable path, or content over the
/// size cap (no diff shown then). Only populated by `for_message` (history).
pub preview_old: Option<String>,
pub preview_new: Option<String>,
}
/// Inserts a tool call in `running` state and returns its id.
@@ -55,6 +62,25 @@ pub async fn complete(pool: &SqlitePool, id: i64, result: &str, result_type: &st
Ok(())
}
/// Persists a file-write tool's before/after snapshot (the diff preview) on its row.
/// Both `None` is a valid no-op state (non-write tool, unreadable path, or content
/// over the size cap). Separate from [`complete`] so the status/result write and the
/// preview write stay independent, and so it can run for both the live and resume paths.
pub async fn set_preview(
pool: &SqlitePool,
id: i64,
old: Option<&str>,
new: Option<&str>,
) -> anyhow::Result<()> {
sqlx::query("UPDATE chat_llm_tools SET preview_old = ?, preview_new = ? WHERE id = ?")
.bind(old)
.bind(new)
.bind(id)
.execute(pool)
.await?;
Ok(())
}
pub async fn fail(pool: &SqlitePool, id: i64, error: &str) -> anyhow::Result<()> {
sqlx::query(
"UPDATE chat_llm_tools SET result = ?, status = 'failed' WHERE id = ?",
@@ -117,13 +143,15 @@ pub async fn pending_for_stack(
Ok(rows.into_iter().map(row_to_tool).collect())
}
/// All tool calls for a single assistant message, ordered chronologically.
/// All tool calls for a single assistant message, ordered chronologically. Unlike
/// [`pending_for_stack`], this also reads the diff-preview columns, so the history
/// projection can re-render a write's diff after a page reload.
pub async fn for_message(
pool: &SqlitePool,
message_id: i64,
) -> anyhow::Result<Vec<LlmToolCall>> {
let rows = sqlx::query_as::<_, (i64, i64, String, Option<String>, Option<String>, String, String)>(
"SELECT id, message_id, name, arguments, result, result_type, status
let rows = sqlx::query_as::<_, (i64, i64, String, Option<String>, Option<String>, String, String, Option<String>, Option<String>)>(
"SELECT id, message_id, name, arguments, result, result_type, status, preview_old, preview_new
FROM chat_llm_tools
WHERE message_id = ?
ORDER BY id ASC",
@@ -132,7 +160,28 @@ pub async fn for_message(
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(row_to_tool).collect())
Ok(rows.into_iter()
.map(|(id, message_id, name, arguments, result, result_type, status, preview_old, preview_new)| {
LlmToolCall { id, message_id, name, arguments, result, result_type, status, preview_old, preview_new }
})
.collect())
}
/// A single tool call by id, with its diff-preview columns. Backs the tool-detail
/// page (`GET /api/tools/{id}`). Returns `None` when the id is unknown in this pool.
pub async fn get(pool: &SqlitePool, id: i64) -> anyhow::Result<Option<LlmToolCall>> {
let row = sqlx::query_as::<_, (i64, i64, String, Option<String>, Option<String>, String, String, Option<String>, Option<String>)>(
"SELECT id, message_id, name, arguments, result, result_type, status, preview_old, preview_new
FROM chat_llm_tools
WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row.map(|(id, message_id, name, arguments, result, result_type, status, preview_old, preview_new)| {
LlmToolCall { id, message_id, name, arguments, result, result_type, status, preview_old, preview_new }
}))
}
fn row_to_tool(
@@ -140,5 +189,6 @@ fn row_to_tool(
i64, i64, String, Option<String>, Option<String>, String, String,
),
) -> LlmToolCall {
LlmToolCall { id, message_id, name, arguments, result, result_type, status }
// The resume path (`pending_for_stack`) never needs the diff preview.
LlmToolCall { id, message_id, name, arguments, result, result_type, status, preview_old: None, preview_new: None }
}
+31 -3
View File
@@ -54,6 +54,11 @@ pub struct McpCatalogRow {
pub icon_large_path: Option<String>,
pub friendly_name: Option<String>,
pub description: Option<String>,
/// Manifest-declared friendly tool names: a JSON array of `{name, display_name}`
/// snapshotting the connector's `tools[]` block. Drives the UI card title for an
/// `mcp__<server>__<tool>` call (override > live MCP `title` > prettified name).
/// NULL when the manifest declares none.
pub tool_meta_json: Option<String>,
/// Marketplace build number — the **comparison key** for updates (a feed entry
/// with a higher `version` than this installed one is "update available").
/// Monotonic per connector; `version_string`/`version_release_date` are display
@@ -105,9 +110,26 @@ const SELECT: &str =
script_path, config_schema_json, auth_kind, oauth_provider, oauth_scopes_json, \
deliver_json, role_filter, verify_command, \
verify_script_path, icon_small_path, icon_large_path, friendly_name, \
description, version, version_string, version_release_date, created_at \
description, tool_meta_json, version, version_string, version_release_date, created_at \
FROM mcp_catalog";
/// Parses a catalog row's `tool_meta_json` (a `[{name, display_name}]` array) into
/// the `tool name → display title` override map the runtime feeds into
/// [`McpServerSpec::tool_titles`]. Empty on NULL or malformed JSON — the runtime then
/// falls back to the server's live MCP `title` and finally a prettified raw name.
pub fn parse_tool_titles(tool_meta_json: Option<&str>) -> HashMap<String, String> {
#[derive(serde::Deserialize)]
struct ToolMeta { name: String, display_name: Option<String> }
tool_meta_json
.and_then(|s| serde_json::from_str::<Vec<ToolMeta>>(s).ok())
.map(|metas| {
metas.into_iter()
.filter_map(|m| m.display_name.map(|dn| (m.name, dn)))
.collect()
})
.unwrap_or_default()
}
// ── Reads ────────────────────────────────────────────────────────────────────
pub async fn list(pool: &SqlitePool) -> Result<Vec<McpCatalogRow>> {
@@ -167,6 +189,8 @@ pub struct UpsertCatalog<'a> {
pub icon_large_path: Option<&'a str>,
pub friendly_name: Option<&'a str>,
pub description: Option<&'a str>,
/// JSON array of `{name, display_name}` snapshotting the manifest's `tools[]`.
pub tool_meta_json: Option<String>,
/// Versioning (from the feed). All three `None` for the admin's manual form,
/// which COALESCEs them away rather than blanking an installed entry's version.
pub version: Option<i64>,
@@ -181,8 +205,8 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
script_path, config_schema_json, auth_kind, oauth_provider, oauth_scopes_json,
deliver_json, role_filter, verify_command,
verify_script_path, icon_small_path, icon_large_path, friendly_name, description,
version, version_string, version_release_date)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)
tool_meta_json, version, version_string, version_release_date)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25)
ON CONFLICT(name) DO UPDATE SET
scope = excluded.scope,
source = excluded.source,
@@ -208,6 +232,9 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
icon_large_path = COALESCE(excluded.icon_large_path, mcp_catalog.icon_large_path),
friendly_name = excluded.friendly_name,
description = excluded.description,
-- Manifest tool titles are installer-owned like icons: COALESCE so the
-- admin's manual catalog form (which never sends them) can't blank them.
tool_meta_json = COALESCE(excluded.tool_meta_json, mcp_catalog.tool_meta_json),
-- Version fields come from the feed on (re)install; the admin's manual
-- form passes NULL, so COALESCE keeps the installed version rather than
-- wiping it (same rationale as icons above).
@@ -237,6 +264,7 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
.bind(e.icon_large_path)
.bind(e.friendly_name)
.bind(e.description)
.bind(e.tool_meta_json)
.bind(e.version)
.bind(e.version_string)
.bind(e.version_release_date)
+8
View File
@@ -557,6 +557,7 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
icon_large_path TEXT,
friendly_name TEXT,
description TEXT,
tool_meta_json TEXT, -- [{name,display_name}] friendly tool names from the manifest
version INTEGER, -- marketplace build number: the update-comparison key
version_string TEXT, -- semver, display only
version_release_date TEXT, -- ISO date, display only
@@ -569,6 +570,8 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
ensure_column(pool, "mcp_catalog", "oauth_provider", "TEXT").await?;
ensure_column(pool, "mcp_catalog", "oauth_scopes_json", "TEXT").await?;
ensure_column(pool, "mcp_catalog", "deliver_json", "TEXT").await?;
// Manifest-declared friendly tool names (UI card titles) — additive.
ensure_column(pool, "mcp_catalog", "tool_meta_json", "TEXT").await?;
// Versioning columns are additive: the installed `version` integer is compared
// against the feed's to surface "update available" in the marketplace UI.
ensure_column(pool, "mcp_catalog", "version", "INTEGER").await?;
@@ -745,11 +748,16 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
result TEXT,
status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running', 'pending', 'done', 'failed', 'cancelled', 'rejected')),
result_type TEXT NOT NULL DEFAULT 'string' CHECK(result_type IN ('string', 'json')),
preview_old TEXT, -- file-write diff: content before the write
preview_new TEXT, -- file-write diff: content after the write
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
)
.execute(pool)
.await?;
// Diff-preview columns are additive — reach an already-created table in place.
ensure_column(pool, "chat_llm_tools", "preview_old", "TEXT").await?;
ensure_column(pool, "chat_llm_tools", "preview_new", "TEXT").await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_stack_session ON chat_sessions_stack(session_id)",