tool card UI redesign: semantic icons, inline diff persistence, tool detail page, MCP-friendly titles
Nightly Build / build (push) Successful in 6m38s
Nightly Build / build (push) Successful in 6m38s
This commit is contained in:
@@ -2,7 +2,93 @@ import { html, nothing } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { renderMarkdown } from '../lib/base.js';
|
||||
import { openFile } from '../lib/open-file.js';
|
||||
import { openToolDetail } from '../lib/open-tool.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
import { connectorIconUrl } from './shared/connector-common.js';
|
||||
|
||||
// ── Tool icons ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Maps a tool's semantic `icon` key (from the backend `Tool::icon`) to a Bootstrap
|
||||
* glyph + a CSS accent class. The key commits to meaning; the look lives here and in
|
||||
* `copilot-messages.css` (theme-aware, no hardcoded colors). Unknown keys fall back
|
||||
* to the generic wrench.
|
||||
*/
|
||||
const TOOL_ICON = {
|
||||
edit: { glyph: 'bi-pencil-square', cls: 'tool-ico--edit' },
|
||||
read: { glyph: 'bi-file-earmark-text', cls: 'tool-ico--read' },
|
||||
list: { glyph: 'bi-folder2-open', cls: 'tool-ico--list' },
|
||||
search: { glyph: 'bi-search', cls: 'tool-ico--search' },
|
||||
shell: { glyph: 'bi-terminal', cls: 'tool-ico--shell' },
|
||||
subagent: { glyph: 'bi-diagram-3', cls: 'tool-ico--subagent' },
|
||||
image: { glyph: 'bi-image', cls: 'tool-ico--image' },
|
||||
config: { glyph: 'bi-sliders', cls: 'tool-ico--config' },
|
||||
introspection: { glyph: 'bi-info-circle', cls: 'tool-ico--introspection' },
|
||||
file: { glyph: 'bi-file-earmark', cls: 'tool-ico--file' },
|
||||
mcp: { glyph: 'bi-plug', cls: 'tool-ico--mcp' },
|
||||
tool: { glyph: 'bi-wrench', cls: 'tool-ico--tool' },
|
||||
};
|
||||
|
||||
/** Whether a tool call carries a file-write diff snapshot to render. */
|
||||
function hasPreview(msg) {
|
||||
return msg.preview_new != null || msg.preview_old != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to render the diff inline on the tool card. Suppressed while a sibling
|
||||
* `pending_write` card for the same call is present (it already shows the diff — an
|
||||
* approval-gated write). After a reload there is no such card, so the tool card
|
||||
* becomes the single place the diff lives. `host` may be absent in bare renders.
|
||||
*/
|
||||
function showInlineDiff(host, msg) {
|
||||
if (!hasPreview(msg)) return false;
|
||||
const siblings = host && host._messages;
|
||||
if (Array.isArray(siblings)
|
||||
&& siblings.some(m => m.kind === 'pending_write' && m.tool_call_id === msg.tool_call_id)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The MCP server name embedded in an `mcp__<server>__<tool>` id, or null. */
|
||||
function mcpServerOf(name) {
|
||||
if (typeof name !== 'string' || !name.startsWith('mcp__')) return null;
|
||||
const rest = name.slice(5);
|
||||
const i = rest.indexOf('__');
|
||||
return i === -1 ? rest : rest.slice(0, i);
|
||||
}
|
||||
|
||||
/**
|
||||
* The leading tool icon for a card. MCP tools show their connector's own icon
|
||||
* (parsed from the `mcp__server__tool` id), falling back to a plug glyph if the
|
||||
* connector shipped none; every other tool shows its semantic glyph + accent.
|
||||
*/
|
||||
function renderToolIcon(msg) {
|
||||
const server = mcpServerOf(msg.name);
|
||||
if (server) {
|
||||
return html`<span class="copilot-tool-ico-wrap">
|
||||
<img class="copilot-tool-ico-img" src=${connectorIconUrl(server, 'sm')} alt=""
|
||||
@error=${(e) => { const w = e.target.closest('.copilot-tool-ico-wrap'); if (w) w.classList.add('img-failed'); }}>
|
||||
<i class="bi bi-plug copilot-tool-ico tool-ico--mcp"></i>
|
||||
</span>`;
|
||||
}
|
||||
const ic = TOOL_ICON[msg.icon] || TOOL_ICON.tool;
|
||||
return html`<i class="bi ${ic.glyph} copilot-tool-ico ${ic.cls}"></i>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The muted secondary detail beside a tool's friendly title: the target path
|
||||
* (clickable) or the primary argument. Derived from `label_full` by stripping the
|
||||
* leading raw tool-name token — which the friendly `display_name` now replaces — so
|
||||
* an MCP tool (whose label is just its raw id) shows no redundant secondary.
|
||||
*/
|
||||
function toolSecondary(msg) {
|
||||
let rest = msg.label_full || '';
|
||||
if (msg.name && rest.startsWith(msg.name)) rest = rest.slice(msg.name.length);
|
||||
rest = rest.trim();
|
||||
if (!rest) return nothing;
|
||||
return html`<span class="copilot-tool-detail">${renderLabel(rest, msg.path)}</span>`;
|
||||
}
|
||||
|
||||
// ── Utilities ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -197,9 +283,21 @@ export function renderTool(host, msg) {
|
||||
<div class="copilot-tool ${isPending ? 'copilot-tool--pending' : ''}">
|
||||
<button class="copilot-tool-header" @click=${() => host._toggleExpand(msg.tool_call_id)}>
|
||||
<span class="copilot-tool-status">${statusIcon}</span>
|
||||
<span class="copilot-tool-name">${renderLabel(msg.label_full || msg.name, msg.path)}</span>
|
||||
${renderToolIcon(msg)}
|
||||
<span class="copilot-tool-name">
|
||||
<span class="copilot-tool-title">${msg.display_name || msg.label_full || msg.name}</span>
|
||||
${toolSecondary(msg)}
|
||||
</span>
|
||||
${isPending ? html`<span class="badge bg-warning text-dark ms-2">${t('approval.pending')}</span>` : nothing}
|
||||
<i class="bi bi-chevron-${isOpen ? 'up' : 'down'} ms-auto"></i>
|
||||
${msg.status !== 'running' ? html`
|
||||
<span class="copilot-tool-eye ms-auto" role="button" tabindex="0"
|
||||
title=${t('copilot.view_details')}
|
||||
@click=${(e) => { e.stopPropagation(); openToolDetail(msg.tool_call_id); }}
|
||||
@keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); openToolDetail(msg.tool_call_id); } }}>
|
||||
<i class="bi bi-eye"></i>
|
||||
</span>
|
||||
<i class="bi bi-chevron-${isOpen ? 'up' : 'down'}"></i>
|
||||
` : html`<i class="bi bi-chevron-${isOpen ? 'up' : 'down'} ms-auto"></i>`}
|
||||
</button>
|
||||
${isOpen ? html`
|
||||
<div class="copilot-tool-body">
|
||||
@@ -209,6 +307,12 @@ export function renderTool(host, msg) {
|
||||
<pre class="copilot-tool-pre">${argsStr}</pre>
|
||||
</div>
|
||||
` : nothing}
|
||||
${showInlineDiff(host, msg) ? html`
|
||||
<div class="copilot-tool-section">
|
||||
<span class="copilot-tool-label">${t('copilot.changes')}</span>
|
||||
<pre class="copilot-diff">${renderDiff(msg.preview_old || '', msg.preview_new || '')}</pre>
|
||||
</div>
|
||||
` : nothing}
|
||||
${isPending ? (msg.name === 'ask_user_clarification' ? html`
|
||||
<div class="copilot-approval-actions">
|
||||
${msg.question_title ? html`<div class="copilot-clarification-title">${msg.question_title}</div>` : nothing}
|
||||
|
||||
@@ -78,7 +78,7 @@ export class AppCopilot extends I18nMixin(ChatSession) {
|
||||
_pageFromHash() {
|
||||
const m = location.hash.slice(1).match(/^([^/?]+)/);
|
||||
const seg = m ? m[1] : '';
|
||||
const known = ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'connectors', 'connector', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'];
|
||||
const known = ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'connectors', 'connector', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer', 'tool_detail'];
|
||||
return known.includes(seg) ? seg : 'home';
|
||||
}
|
||||
|
||||
|
||||
@@ -5,14 +5,16 @@ import './shared/chat-page.js';
|
||||
import './shared/projects-page.js';
|
||||
import './shared/settings-page.js';
|
||||
import './shared/file-viewer-mobile.js';
|
||||
import './shared/tool-detail-mobile.js';
|
||||
|
||||
// Sections addressable via the URL hash — same routing style as the desktop
|
||||
// sidebar (web/components/sidebar.js). The native iOS shell and mobile browsers
|
||||
// share this router so the URL always reflects the active section: native menu
|
||||
// sync, deep links, and back/refresh restoration all flow from one place.
|
||||
// `file_viewer` is not a tab — it's opened from content (a clickable tool path
|
||||
// via openFile() → `#file_viewer?path=...`) and so has no bottom-nav entry.
|
||||
const VALID_SECTIONS = ['inbox', 'projects', 'chat', 'notifications', 'settings', 'file_viewer'];
|
||||
// `file_viewer` / `tool_detail` are not tabs — they're opened from content (a
|
||||
// clickable tool path via openFile() → `#file_viewer?path=...`, or a tool card's
|
||||
// eye via openToolDetail() → `#tool_detail?id=...`) and have no bottom-nav entry.
|
||||
const VALID_SECTIONS = ['inbox', 'projects', 'chat', 'notifications', 'settings', 'file_viewer', 'tool_detail'];
|
||||
|
||||
class MobileApp extends LitElement {
|
||||
// No shadow DOM — lets external CSS and Bootstrap Icons apply directly.
|
||||
@@ -26,6 +28,8 @@ class MobileApp extends LitElement {
|
||||
_chatLabel: { state: true },
|
||||
// File shown by the file_viewer section (from `#file_viewer?path=...`).
|
||||
_filePath: { state: true },
|
||||
// Tool call shown by the tool_detail section (from `#tool_detail?id=...`).
|
||||
_toolId: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
@@ -34,6 +38,7 @@ class MobileApp extends LitElement {
|
||||
this._chatSource = 'mobile';
|
||||
this._chatLabel = '';
|
||||
this._filePath = null;
|
||||
this._toolId = null;
|
||||
// id → name cache, so a cold deep-link (#chat/project-<id> opened by the
|
||||
// native shell) can resolve its header label without the project list open.
|
||||
this._projectLabels = {};
|
||||
@@ -69,9 +74,9 @@ class MobileApp extends LitElement {
|
||||
// #file_viewer?path=<enc> → section 'file_viewer' showing a file
|
||||
_readHash() {
|
||||
const raw = location.hash.slice(1);
|
||||
if (!raw) return { section: 'chat', projectId: null, filePath: null };
|
||||
if (!raw) return { section: 'chat', projectId: null, filePath: null, toolId: null };
|
||||
// Segment ends at the first `/` (project sub-route) or `?` (query, e.g. the
|
||||
// file viewer's `?path=`).
|
||||
// file viewer's `?path=` / the tool detail's `?id=`).
|
||||
const cut = raw.search(/[/?]/);
|
||||
const seg = cut === -1 ? raw : raw.slice(0, cut);
|
||||
const section = VALID_SECTIONS.includes(seg) ? seg : 'chat';
|
||||
@@ -79,7 +84,13 @@ class MobileApp extends LitElement {
|
||||
let filePath = null;
|
||||
const m = raw.match(/[?&]path=([^&]*)/);
|
||||
if (m) { try { filePath = decodeURIComponent(m[1]); } catch { /* keep null */ } }
|
||||
return { section, projectId: null, filePath };
|
||||
return { section, projectId: null, filePath, toolId: null };
|
||||
}
|
||||
if (section === 'tool_detail') {
|
||||
let toolId = null;
|
||||
const m = raw.match(/[?&]id=([^&]*)/);
|
||||
if (m) { try { toolId = decodeURIComponent(m[1]); } catch { /* keep null */ } }
|
||||
return { section, projectId: null, filePath: null, toolId };
|
||||
}
|
||||
const slash = raw.indexOf('/');
|
||||
const sub = slash === -1 ? '' : raw.slice(slash + 1);
|
||||
@@ -87,13 +98,14 @@ class MobileApp extends LitElement {
|
||||
if (section === 'chat' && sub.startsWith('project-')) {
|
||||
projectId = sub.slice('project-'.length) || null;
|
||||
}
|
||||
return { section, projectId, filePath: null };
|
||||
return { section, projectId, filePath: null, toolId: null };
|
||||
}
|
||||
|
||||
_applyHash() {
|
||||
const { section, projectId, filePath } = this._readHash();
|
||||
const { section, projectId, filePath, toolId } = this._readHash();
|
||||
this._section = section;
|
||||
this._filePath = filePath;
|
||||
this._toolId = toolId;
|
||||
if (projectId) {
|
||||
const source = 'project-' + projectId;
|
||||
if (this._chatSource !== source) this._chatSource = source;
|
||||
@@ -198,6 +210,11 @@ class MobileApp extends LitElement {
|
||||
.path=${this._filePath}
|
||||
style=${s === 'file_viewer' ? 'flex:1;min-height:0;overflow:hidden' : 'display:none'}
|
||||
></mobile-file-viewer-page>
|
||||
<mobile-tool-detail-page
|
||||
.visible=${s === 'tool_detail'}
|
||||
tool-id=${this._toolId ?? nothing}
|
||||
style=${s === 'tool_detail' ? 'flex:1;min-height:0;overflow:auto' : 'display:none'}
|
||||
></mobile-tool-detail-page>
|
||||
<settings-page
|
||||
.visible=${s === 'settings'}
|
||||
style=${s === 'settings' ? 'flex:1;min-height:0;overflow:hidden' : 'display:none'}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { LitElement, html, nothing } from 'lit';
|
||||
import { t } from '../../lib/i18n.js';
|
||||
import { fetchToolDetail, renderToolBody, STATUS_ICON } from './tool-detail-view.js';
|
||||
|
||||
/**
|
||||
* Mobile tool-execution detail page. Same shared engine as the desktop
|
||||
* `<tool-detail-page>`, but prop-driven: `<mobile-app>` binds `visible` / `toolId`
|
||||
* from its hash router (`#tool_detail?id=...`) instead of the component listening to
|
||||
* the hash. The back button returns to the previous mobile section via history.
|
||||
*/
|
||||
export class MobileToolDetailPage extends LitElement {
|
||||
// No shadow DOM — inherit the app's global CSS + Bootstrap Icons.
|
||||
createRenderRoot() { return this; }
|
||||
|
||||
static properties = {
|
||||
visible: { type: Boolean },
|
||||
toolId: { attribute: 'tool-id' },
|
||||
_loading: { state: true },
|
||||
_error: { state: true },
|
||||
_tool: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.visible = false;
|
||||
this.toolId = null;
|
||||
this._loading = false;
|
||||
this._error = null;
|
||||
this._tool = null;
|
||||
}
|
||||
|
||||
updated(changed) {
|
||||
if (changed.has('visible') || changed.has('toolId')) {
|
||||
if (this.visible && this.toolId != null) this._load();
|
||||
}
|
||||
}
|
||||
|
||||
async _load() {
|
||||
this._loading = true;
|
||||
this._error = null;
|
||||
this._tool = null;
|
||||
try {
|
||||
this._tool = await fetchToolDetail(this.toolId);
|
||||
} catch (e) {
|
||||
this._error = e.message || String(e);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
_back() { history.back(); }
|
||||
|
||||
render() {
|
||||
if (!this.visible) return nothing;
|
||||
const tl = this._tool;
|
||||
const si = tl ? (STATUS_ICON[tl.status] || STATUS_ICON.done) : null;
|
||||
return html`
|
||||
<div class="mobile-tool-detail tool-detail-page">
|
||||
<div class="mobile-section-header">
|
||||
<span class="mobile-section-title">
|
||||
<button class="chat-page-back" title=${t('fv.back')} @click=${() => this._back()}>
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</button>
|
||||
<span>
|
||||
${si ? html`<i class="bi ${si.glyph} ${si.cls} me-1"></i>` : nothing}
|
||||
${tl ? (tl.display_name || tl.name) : t('tool_detail.title')}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="tool-detail-body">
|
||||
${this._loading ? html`<div class="tool-detail-muted">${t('common.loading')}</div>` : nothing}
|
||||
${this._error ? html`<div class="alert alert-danger">${this._error}</div>` : nothing}
|
||||
${renderToolBody(tl)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('mobile-tool-detail-page', MobileToolDetailPage);
|
||||
@@ -0,0 +1,77 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { t } from '../../lib/i18n.js';
|
||||
import { openFile } from '../../lib/open-file.js';
|
||||
import { renderDiff } from '../copilot-render.js';
|
||||
|
||||
// Shared engine for the tool-execution detail view — the fetch + the center-panel
|
||||
// body — so the desktop (`tool-detail-page.js`) and mobile
|
||||
// (`shared/tool-detail-mobile.js`) surfaces render identically and only supply
|
||||
// their own chrome (header / back button).
|
||||
|
||||
export const STATUS_ICON = {
|
||||
done: { glyph: 'bi-check-circle-fill', cls: 'text-success' },
|
||||
error: { glyph: 'bi-x-circle-fill', cls: 'text-danger' },
|
||||
cancelled: { glyph: 'bi-slash-circle-fill', cls: 'text-secondary' },
|
||||
rejected: { glyph: 'bi-shield-fill-x', cls: 'text-warning' },
|
||||
pending: { glyph: 'bi-hourglass-split', cls: 'text-warning' },
|
||||
};
|
||||
|
||||
function prettyJson(v) {
|
||||
if (v == null) return '';
|
||||
try { return JSON.stringify(v, null, 2); }
|
||||
catch { return String(v); }
|
||||
}
|
||||
|
||||
/** Fetches one tool call's full detail from `GET /api/tools/{id}`. Throws on error. */
|
||||
export async function fetchToolDetail(id) {
|
||||
const r = await fetch(`/api/tools/${encodeURIComponent(id)}`, { credentials: 'same-origin' });
|
||||
if (!r.ok) throw new Error((await r.text()) || `HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function renderResult(tl) {
|
||||
if (tl.status === 'error' || tl.status === 'cancelled' || tl.status === 'rejected') {
|
||||
return html`<pre class="tool-detail-pre tool-detail-pre--error">${tl.error ?? tl.result ?? ''}</pre>`;
|
||||
}
|
||||
if (tl.status === 'pending') {
|
||||
return html`<div class="tool-detail-muted">${t('approval.pending')}</div>`;
|
||||
}
|
||||
let body = tl.result ?? '';
|
||||
if (tl.result_type === 'json') {
|
||||
try { body = prettyJson(JSON.parse(tl.result ?? 'null')); } catch { /* keep raw */ }
|
||||
}
|
||||
return html`<pre class="tool-detail-pre">${body}</pre>`;
|
||||
}
|
||||
|
||||
/** The center-panel body: target path, input args, diff (writes), and result. */
|
||||
export function renderToolBody(tl) {
|
||||
if (!tl) return nothing;
|
||||
const hasPreview = tl.preview_new != null || tl.preview_old != null;
|
||||
return html`
|
||||
${tl.path ? html`
|
||||
<div class="tool-detail-section">
|
||||
<span class="tool-detail-label">${t('tool_detail.target')}</span>
|
||||
<button class="tool-detail-path" @click=${() => openFile(tl.path)}>
|
||||
<i class="bi bi-file-earmark-text me-1"></i>${tl.path}
|
||||
</button>
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
<div class="tool-detail-section">
|
||||
<span class="tool-detail-label">${t('tool_detail.input')}</span>
|
||||
<pre class="tool-detail-pre">${prettyJson(tl.arguments)}</pre>
|
||||
</div>
|
||||
|
||||
${hasPreview ? html`
|
||||
<div class="tool-detail-section">
|
||||
<span class="tool-detail-label">${t('copilot.changes')}</span>
|
||||
<pre class="copilot-diff">${renderDiff(tl.preview_old || '', tl.preview_new || '')}</pre>
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
<div class="tool-detail-section">
|
||||
<span class="tool-detail-label">${t('copilot.result')}</span>
|
||||
${renderResult(tl)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -219,7 +219,7 @@ export class AppSidebar extends I18nMixin(LightElement) {
|
||||
return m ? `plugin/${m[1]}/${m[2]}` : 'home';
|
||||
}
|
||||
// `connector` (singular) is the per-connector detail page, `connectors` the list.
|
||||
return ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'shared-folders', 'connectors', 'connector', 'plugins', 'plugin-catalog', 'plugin-detail', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer'].includes(segment) ? segment : 'home';
|
||||
return ['inbox', 'dashboard', 'tasks', 'projects', 'models', 'providers', 'approval', 'agents', 'users', 'roles', 'shared-folders', 'connectors', 'connector', 'plugins', 'plugin-catalog', 'plugin-detail', 'catalog', 'marketplace', 'profile', 'config', 'llm-requests', 'session', 'tic', 'file_viewer', 'tool_detail'].includes(segment) ? segment : 'home';
|
||||
}
|
||||
|
||||
_tasksSectionFromHash() {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t } from '../lib/i18n.js';
|
||||
import { fetchToolDetail, renderToolBody, STATUS_ICON } from './shared/tool-detail-view.js';
|
||||
|
||||
const PAGE_ID = 'tool_detail';
|
||||
|
||||
function idFromHash() {
|
||||
const h = location.hash;
|
||||
const prefix = `#${PAGE_ID}?id=`;
|
||||
if (!h.startsWith(prefix)) return null;
|
||||
try {
|
||||
return decodeURIComponent(h.slice(prefix.length));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop tool-execution detail page. Self-routes off the hash
|
||||
* (`#tool_detail?id=...`), mirroring `file-viewer-page.js`: the sidebar's
|
||||
* `llm-page-change` event toggles visibility and `hashchange` re-loads. It hydrates
|
||||
* from `GET /api/tools/{id}` (via the shared `tool-detail-view` engine) so a tool's
|
||||
* input / result / diff is readable in the center panel even after a page reload.
|
||||
*/
|
||||
export class ToolDetailPage extends LightElement {
|
||||
static properties = {
|
||||
_open: { state: true },
|
||||
_loading: { state: true },
|
||||
_error: { state: true },
|
||||
_tool: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._open = false;
|
||||
this._loading = false;
|
||||
this._error = null;
|
||||
this._tool = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('llm-page-change', (e) => {
|
||||
this._open = e.detail.page === PAGE_ID;
|
||||
this.style.display = this._open ? 'flex' : 'none';
|
||||
if (this._open) this._loadFromHash();
|
||||
});
|
||||
window.addEventListener('hashchange', () => {
|
||||
if (this._open) this._loadFromHash();
|
||||
});
|
||||
}
|
||||
|
||||
async _loadFromHash() {
|
||||
const id = idFromHash();
|
||||
if (id == null) return;
|
||||
this._loading = true;
|
||||
this._error = null;
|
||||
this._tool = null;
|
||||
try {
|
||||
this._tool = await fetchToolDetail(id);
|
||||
} catch (e) {
|
||||
this._error = e.message || String(e);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
_back() { history.back(); }
|
||||
|
||||
render() {
|
||||
if (!this._open) return nothing;
|
||||
const tl = this._tool;
|
||||
const si = tl ? (STATUS_ICON[tl.status] || STATUS_ICON.done) : null;
|
||||
return html`
|
||||
<div class="llm-page tool-detail-page">
|
||||
<div class="llm-page-header">
|
||||
<div class="llm-header-left">
|
||||
<button class="btn btn-sm btn-outline-secondary back-btn" title=${t('fv.back')} @click=${() => this._back()}>
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</button>
|
||||
<h2 class="llm-page-title">
|
||||
${tl ? html`
|
||||
${si ? html`<i class="bi ${si.glyph} ${si.cls} me-2"></i>` : nothing}
|
||||
${tl.display_name || tl.name}
|
||||
` : t('tool_detail.title')}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-detail-body">
|
||||
${this._loading ? html`<div class="tool-detail-muted">${t('common.loading')}</div>` : nothing}
|
||||
${this._error ? html`<div class="alert alert-danger">${this._error}</div>` : nothing}
|
||||
${renderToolBody(tl)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user