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:
+4
-1
@@ -32,11 +32,13 @@ import { SessionDetailPage } from './components/session-detail.js';
|
||||
import { TicSessionsPage } from './components/tic-sessions.js';
|
||||
import { ProjectsPage } from './components/projects/index.js';
|
||||
import { FileViewerPage } from './components/file-viewer-page.js';
|
||||
import { ToolDetailPage } from './components/tool-detail-page.js';
|
||||
import { SetupPage } from './components/setup-page.js';
|
||||
import { LoginPage } from './components/login-page.js';
|
||||
|
||||
// Register the global `openFile(path)` helper (window.openFile → location.hash).
|
||||
// Register the global `openFile(path)` / `openToolDetail(id)` helpers.
|
||||
import './lib/open-file.js';
|
||||
import './lib/open-tool.js';
|
||||
import { initI18n } from './lib/i18n.js';
|
||||
|
||||
customElements.define('app-topbar', AppTopbar);
|
||||
@@ -73,6 +75,7 @@ customElements.define('session-detail-page', SessionDetailPage);
|
||||
customElements.define('tic-sessions-page', TicSessionsPage);
|
||||
customElements.define('projects-page', ProjectsPage);
|
||||
customElements.define('file-viewer-page', FileViewerPage);
|
||||
customElements.define('tool-detail-page', ToolDetailPage);
|
||||
customElements.define('setup-page', SetupPage);
|
||||
customElements.define('login-page', LoginPage);
|
||||
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -131,13 +131,67 @@
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* The leading tool-type icon: a colored glyph, or an MCP connector's own icon. */
|
||||
.copilot-tool-ico {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
color: var(--tool-generic);
|
||||
}
|
||||
.copilot-tool-ico-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.copilot-tool-ico-img {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
object-fit: contain;
|
||||
border-radius: 3px;
|
||||
}
|
||||
/* Fallback: hide the glyph unless the connector image failed to load. */
|
||||
.copilot-tool-ico-wrap .copilot-tool-ico { display: none; }
|
||||
.copilot-tool-ico-wrap.img-failed .copilot-tool-ico-img { display: none; }
|
||||
.copilot-tool-ico-wrap.img-failed .copilot-tool-ico { display: inline; }
|
||||
|
||||
.tool-ico--edit { color: var(--tool-edit); }
|
||||
.tool-ico--read { color: var(--tool-read); }
|
||||
.tool-ico--list { color: var(--tool-list); }
|
||||
.tool-ico--search { color: var(--tool-search); }
|
||||
.tool-ico--shell { color: var(--tool-shell); }
|
||||
.tool-ico--subagent { color: var(--tool-subagent); }
|
||||
.tool-ico--image { color: var(--tool-image); }
|
||||
.tool-ico--config { color: var(--tool-config); }
|
||||
.tool-ico--introspection { color: var(--tool-introspection); }
|
||||
.tool-ico--mcp { color: var(--tool-mcp); }
|
||||
.tool-ico--file,
|
||||
.tool-ico--tool { color: var(--tool-generic); }
|
||||
|
||||
.copilot-tool-name {
|
||||
font-weight: 400;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The friendly, static tool title ("Edit File") — the primary label. */
|
||||
.copilot-tool-title {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--msg-assistant-text);
|
||||
}
|
||||
|
||||
/* The muted secondary detail (target path / command / primary arg). */
|
||||
.copilot-tool-detail {
|
||||
font-family: var(--bs-font-monospace);
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
color: var(--placeholder-color);
|
||||
}
|
||||
|
||||
.copilot-tool-name code {
|
||||
@@ -583,3 +637,86 @@
|
||||
}
|
||||
|
||||
.attach-chip-remove:hover { background: var(--sidebar-hover); color: #dc2626; }
|
||||
|
||||
/* ── Tool card "view details" (eye) ────────────────────────────────────────── */
|
||||
|
||||
.copilot-tool-eye {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 0.25rem;
|
||||
color: var(--placeholder-color);
|
||||
border-radius: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.copilot-tool-eye:hover { color: var(--accent); background: var(--accent-soft); }
|
||||
|
||||
/* ── Tool-detail page (#tool_detail) ───────────────────────────────────────── */
|
||||
|
||||
.tool-detail-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tool-detail-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tool-detail-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.tool-detail-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--placeholder-color);
|
||||
}
|
||||
|
||||
.tool-detail-pre {
|
||||
font-family: var(--bs-font-monospace);
|
||||
font-size: 0.82rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin: 0;
|
||||
padding: 0.7rem 0.9rem;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid var(--toolbar-border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.tool-detail-pre--error { color: #dc2626; }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.tool-detail-pre { background: rgba(255, 255, 255, 0.04); }
|
||||
}
|
||||
|
||||
.tool-detail-muted { color: var(--placeholder-color); font-size: 0.85rem; }
|
||||
|
||||
.tool-detail-path {
|
||||
align-self: flex-start;
|
||||
font-family: var(--bs-font-monospace);
|
||||
font-size: 0.82rem;
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.3rem 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tool-detail-path:hover { text-decoration: underline; }
|
||||
|
||||
/* The detail page's diff fills the panel rather than the compact card height. */
|
||||
.tool-detail-page .copilot-diff {
|
||||
max-height: none;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,20 @@
|
||||
--accent-soft: rgba(217, 93, 78, 0.12);
|
||||
--accent-ring: rgba(217, 93, 78, 0.28);
|
||||
|
||||
/* Tool-card accents — one hue per tool category (semantic icon key).
|
||||
Light mode; the dark block below lifts them for contrast. */
|
||||
--tool-edit: #2563eb;
|
||||
--tool-read: #0891b2;
|
||||
--tool-list: #7c3aed;
|
||||
--tool-search: #9333ea;
|
||||
--tool-shell: #475569;
|
||||
--tool-subagent: #0d9488;
|
||||
--tool-image: #db2777;
|
||||
--tool-config: #ca8a04;
|
||||
--tool-introspection: #6b7280;
|
||||
--tool-mcp: var(--accent);
|
||||
--tool-generic: #8a7a66;
|
||||
|
||||
/* Radius scale — friendly, generous */
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
@@ -66,6 +80,18 @@
|
||||
--accent-soft: rgba(232, 131, 111, 0.16);
|
||||
--accent-ring: rgba(232, 131, 111, 0.35);
|
||||
|
||||
/* Tool-card accents — lifted for dark backgrounds */
|
||||
--tool-edit: #60a5fa;
|
||||
--tool-read: #22d3ee;
|
||||
--tool-list: #a78bfa;
|
||||
--tool-search: #c084fc;
|
||||
--tool-shell: #94a3b8;
|
||||
--tool-subagent: #2dd4bf;
|
||||
--tool-image: #f472b6;
|
||||
--tool-config: #eab308;
|
||||
--tool-introspection: #9ca3af;
|
||||
--tool-generic: #b3a28c;
|
||||
|
||||
--sidebar-bg: #241f1a;
|
||||
--sidebar-hover: rgba(236, 225, 211, 0.06);
|
||||
--sidebar-active-bg: rgba(232, 131, 111, 0.16);
|
||||
|
||||
@@ -91,6 +91,11 @@ export default {
|
||||
'copilot.not_sent_to_llm': 'This message is not sent to the LLM',
|
||||
'copilot.remove': 'Remove',
|
||||
'copilot.result_json': 'result · json',
|
||||
'copilot.changes': 'changes',
|
||||
'copilot.view_details': 'View details',
|
||||
'tool_detail.title': 'Tool call',
|
||||
'tool_detail.target': 'target',
|
||||
'tool_detail.input': 'input',
|
||||
'copilot.agent_done': 'done',
|
||||
'copilot.agent_running': 'running…',
|
||||
'copilot.agent_finished': 'finished',
|
||||
|
||||
@@ -91,6 +91,11 @@ export default {
|
||||
'copilot.not_sent_to_llm': 'Ce message n\'est pas envoyé au LLM',
|
||||
'copilot.remove': 'Supprimer',
|
||||
'copilot.result_json': 'résultat · json',
|
||||
'copilot.changes': 'modifications',
|
||||
'copilot.view_details': 'Voir les détails',
|
||||
'tool_detail.title': 'Appel d\'outil',
|
||||
'tool_detail.target': 'cible',
|
||||
'tool_detail.input': 'entrée',
|
||||
'copilot.agent_done': 'terminé',
|
||||
'copilot.agent_running': 'en cours…',
|
||||
'copilot.agent_finished': 'fini',
|
||||
|
||||
@@ -91,6 +91,11 @@ export default {
|
||||
'copilot.not_sent_to_llm': 'Questo messaggio non viene inviato all\'LLM',
|
||||
'copilot.remove': 'Rimuovi',
|
||||
'copilot.result_json': 'risultato · json',
|
||||
'copilot.changes': 'modifiche',
|
||||
'copilot.view_details': 'Vedi dettagli',
|
||||
'tool_detail.title': 'Chiamata tool',
|
||||
'tool_detail.target': 'target',
|
||||
'tool_detail.input': 'input',
|
||||
'copilot.agent_done': 'completato',
|
||||
'copilot.agent_running': 'in esecuzione…',
|
||||
'copilot.agent_finished': 'finito',
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
<tic-sessions-page style="display:none"></tic-sessions-page>
|
||||
<projects-page style="display:none"></projects-page>
|
||||
<file-viewer-page style="display:none"></file-viewer-page>
|
||||
<tool-detail-page style="display:none"></tool-detail-page>
|
||||
<app-copilot></app-copilot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -314,6 +314,8 @@ export class ChatSession extends LightElement {
|
||||
kind: 'tool',
|
||||
tool_call_id: msg.tool_call_id,
|
||||
name: msg.name,
|
||||
display_name: msg.display_name,
|
||||
icon: msg.icon,
|
||||
label_short: msg.label_short,
|
||||
label_full: msg.label_full,
|
||||
path: msg.path,
|
||||
@@ -327,7 +329,12 @@ export class ChatSession extends LightElement {
|
||||
}
|
||||
|
||||
case 'tool_done':
|
||||
this._updateTool(msg.tool_call_id, { status: 'done', result: msg.result, result_type: msg.result_type });
|
||||
// `preview_old`/`preview_new` are present only for a file-write; they let the
|
||||
// card render the diff inline even for an auto-allowed write (no PendingWrite).
|
||||
this._updateTool(msg.tool_call_id, {
|
||||
status: 'done', result: msg.result, result_type: msg.result_type,
|
||||
preview_old: msg.preview_old, preview_new: msg.preview_new,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'tool_error':
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Global tool-detail opener helper.
|
||||
*
|
||||
* `window.openToolDetail(id)` is the single entry point for "show this tool
|
||||
* call's full input / result / diff in the dedicated detail page". It navigates
|
||||
* to `#tool_detail?id=<id>`, which the hash router in `sidebar.js` resolves to
|
||||
* the `<tool-detail-page>` element. Back/forward navigation works naturally.
|
||||
*
|
||||
* Mirrors `open-file.js` — the URL format lives in one place, so a tool card's
|
||||
* "details" (eye) affordance calls this rather than setting the hash directly.
|
||||
*/
|
||||
export function openToolDetail(id) {
|
||||
if (id == null) return;
|
||||
location.hash = `tool_detail?id=${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
window.openToolDetail = openToolDetail;
|
||||
Reference in New Issue
Block a user