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
+16
View File
@@ -201,6 +201,16 @@ struct VerifySpec {
#[serde(default)] timeout_secs: Option<u64>,
}
/// One entry of the manifest's optional `tools[]` block: a friendly display name
/// for a raw MCP tool. Snapshotted into `mcp_catalog.tool_meta_json` and used as the
/// authoritative UI card title (override > live MCP `title` > prettified raw name).
/// Icons are not per-tool — a connector's own icon covers all its tools.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct ToolMeta {
name: String,
#[serde(default)] display_name: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct Manifest {
#[serde(default)] name: Option<String>,
@@ -226,6 +236,8 @@ struct Manifest {
#[serde(default)] env: Vec<EnvEntry>,
/// Optional verify-before-save command.
#[serde(default)] verify: Option<VerifySpec>,
/// Optional friendly display names for the connector's tools (UI card titles).
#[serde(default)] tools: Vec<ToolMeta>,
}
#[derive(Debug, Clone)]
@@ -762,6 +774,10 @@ pub async fn install(
.llm_short_description
.as_deref()
.or(h.entry.user_description.as_deref()),
// Snapshot the manifest's friendly tool names for the UI card titles.
tool_meta_json: (!h.manifest.tools.is_empty())
.then(|| serde_json::to_string(&h.manifest.tools).ok())
.flatten(),
// Snapshot the feed's version so a later listing can compare it against a
// newer feed and surface "update available". Manifest wins over index.
version: h.manifest.version.or(h.entry.version),
+3
View File
@@ -322,6 +322,9 @@ pub async fn catalog_upsert(
icon_large_path: None,
friendly_name: body.friendly_name.as_deref(),
description: body.description.as_deref(),
// Friendly tool names are the feed's to set; the manual form leaves them
// untouched (COALESCE in `upsert`).
tool_meta_json: None,
// Versioning is the feed's to set (marketplace install); the manual form
// leaves it untouched (COALESCE in `upsert`).
version: None,
+2
View File
@@ -68,6 +68,8 @@ pub fn router() -> Router<Arc<Skald>> {
// File attachments: streamed to disk, so the default body-size limit is
// disabled on this route only.
.route("/{source}/uploads", post(uploads::upload).layer(DefaultBodyLimit::disable()))
// Full execution detail for one tool call (the tool-detail page).
.route("/tools/{tool_call_id}", get(sessions::tool_detail))
// Source-agnostic approval resolve, keyed by globally-unique tool_call_id.
.route("/tools/{tool_call_id}/resolve", post(sessions::resolve_tool))
// Back-compat alias for older web clients that POST to /web/tools/...
+93 -2
View File
@@ -17,6 +17,7 @@ use std::sync::Arc;
use skald_core::skald::{Skald, UserContext};
use skald_core::session::handler::ApprovalDecision;
use skald_core::approval::ApprovalManager;
use skald_core::mcp::{McpManager, parse_mcp_tool_name};
use skald_core::tools::{ToolRegistry, ToolDescriptionLength, tool_names as tn};
use super::{ApiError, guard::AuthUser, require_context};
@@ -112,11 +113,35 @@ async fn messages_for_source(skald: &Arc<Skald>, ctx: &UserContext, source: &str
.collect();
let mut items: Vec<Value> = Vec::new();
build_items(db, skald.tools(), &ctx.approval, &main_stack, &subagent_map, &mut items).await?;
build_items(db, skald.tools(), skald.mcp(), &ctx.user_mcp, &ctx.approval, &main_stack, &subagent_map, &mut items).await?;
Ok(Json(items))
}
/// Card metadata (friendly display name + semantic icon key) for a persisted tool
/// call, mirroring the live loop's `tool_ui_meta`: the registry seam, with the MCP
/// display-name override layered on (per-user runtime first, then global) for an
/// `mcp__server__tool` name. History resolves against the running runtimes, so the
/// friendly name survives a refresh; a stopped server falls back to the prettified
/// name the seam produced.
fn tool_card_meta(
tools: &ToolRegistry,
global_mcp: &McpManager,
user_mcp: &McpManager,
name: &str,
args: &Value,
) -> (String, String) {
let mut meta = tools.display_meta(name, args);
if let Some((server, tool)) = parse_mcp_tool_name(name) {
if let Some(friendly) = user_mcp.tool_display_name(server, tool)
.or_else(|| global_mcp.tool_display_name(server, tool))
{
meta.display_name = friendly;
}
}
(meta.display_name, meta.icon)
}
// ── POST /api/tools/:tool_call_id/resolve — approve/reject a pending tool ─────
// (source-agnostic; /api/web/tools/... kept as a back-compat alias)
@@ -269,6 +294,56 @@ pub async fn resolve_tool(
}
}
// ── GET /api/tools/:tool_call_id — full execution detail for the detail page ──
/// One tool call's full record — input args, result, and (for a file-write) the
/// before/after snapshot — for the dedicated tool-detail page. Read from the
/// caller's own pool (the `tool_call_id` is local to it), so ownership is implicit.
pub async fn tool_detail(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<ResolveToolPath>,
) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let tc = chat_llm_tools::get(&ctx.pool, p.tool_call_id).await?
.ok_or_else(|| ApiError::not_found(format!("tool_call_id {} not found", p.tool_call_id)))?;
let args: Value = tc.arguments.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(Value::Null);
// Normalize the DB status the same way as the history projection: an interrupted
// `running` row surfaces as an error, terminal states pass through.
let (status, result, error) = match tc.status.as_str() {
"done" => ("done", tc.result.clone(), None),
"pending" => ("pending", None, None),
"running" => ("error", None, Some("Interrupted.".to_string())),
"cancelled" => ("cancelled", None, tc.result.clone()),
"rejected" => ("rejected", None, tc.result.clone()),
_ => ("error", None, tc.result.clone()),
};
let (display_name, icon) = tool_card_meta(skald.tools(), skald.mcp(), &ctx.user_mcp, &tc.name, &args);
let label_full = skald.tools().describe_call(&tc.name, &args, ToolDescriptionLength::Full);
let target_path = skald.tools().target_path(&tc.name, &args);
Ok(Json(json!({
"tool_call_id": tc.id,
"name": tc.name,
"display_name": display_name,
"icon": icon,
"label_full": label_full,
"path": target_path,
"arguments": args,
"status": status,
"result": result,
"result_type": tc.result_type,
"error": error,
"preview_old": tc.preview_old,
"preview_new": tc.preview_new,
})))
}
// ── GET /api/sessions — list sessions by source (paginated) ──────────────────
#[derive(Deserialize)]
@@ -473,10 +548,16 @@ fn build_debug_items<'a>(
let label_short = tools.describe_call(&tc.name, &args, ToolDescriptionLength::Short);
let label_full = tools.describe_call(&tc.name, &args, ToolDescriptionLength::Full);
let target_path = tools.target_path(&tc.name, &args);
// Debug view has no MCP runtime handy: use the registry seam
// directly (MCP tools fall back to a prettified name + `mcp`
// icon, no live/manifest title override).
let meta = tools.display_meta(&tc.name, &args);
items.push(json!({
"kind": "tool",
"tool_call_id": tc.id,
"name": tc.name,
"display_name": meta.display_name,
"icon": meta.icon,
"label_short": label_short,
"label_full": label_full,
"path": target_path,
@@ -485,6 +566,8 @@ fn build_debug_items<'a>(
"result": result,
"result_type": tc.result_type,
"error": error,
"preview_old": tc.preview_old,
"preview_new": tc.preview_new,
}));
if let Some(sub_stack) = subagent_map.get(&tc.id) {
@@ -513,9 +596,12 @@ fn build_debug_items<'a>(
// ── Recursive message-tree builder ────────────────────────────────────────────
#[allow(clippy::too_many_arguments)]
fn build_items<'a>(
db: &'a SqlitePool,
tools: &'a ToolRegistry,
global_mcp: &'a McpManager,
user_mcp: &'a McpManager,
approval: &'a ApprovalManager,
stack: &'a SessionStack,
subagent_map: &'a HashMap<i64, SessionStack>,
@@ -601,11 +687,14 @@ fn build_items<'a>(
let label_short = tools.describe_call(&tc.name, &args, ToolDescriptionLength::Short);
let label_full = tools.describe_call(&tc.name, &args, ToolDescriptionLength::Full);
let target_path = tools.target_path(&tc.name, &args);
let (display_name, icon) = tool_card_meta(tools, global_mcp, user_mcp, &tc.name, &args);
items.push(json!({
"kind": "tool",
"tool_call_id": tc.id,
"request_id": request_id,
"name": tc.name,
"display_name": display_name,
"icon": icon,
"label_short": label_short,
"label_full": label_full,
"path": target_path,
@@ -614,6 +703,8 @@ fn build_items<'a>(
"result": result,
"result_type": tc.result_type,
"error": error,
"preview_old": tc.preview_old,
"preview_new": tc.preview_new,
}));
if let Some(sub_stack) = subagent_map.get(&tc.id) {
@@ -624,7 +715,7 @@ fn build_items<'a>(
"depth": sub_stack.depth,
"done": true,
}));
build_items(db, tools, approval, sub_stack, subagent_map, items).await?;
build_items(db, tools, global_mcp, user_mcp, approval, sub_stack, subagent_map, items).await?;
items.push(json!({
"kind": "agent_end",
"agent_id": sub_stack.agent_id,