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
+4 -1
View File
@@ -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);
+106 -2
View File
@@ -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}
+1 -1
View File
@@ -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';
}
+25 -8
View File
@@ -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 tabsthey'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);
+77
View File
@@ -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>
`;
}
+1 -1
View File
@@ -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() {
+99
View File
@@ -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>
`;
}
}
+138 -1
View File
@@ -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;
}
+26
View File
@@ -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);
+5
View File
@@ -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',
+5
View File
@@ -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',
+5
View File
@@ -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',
+1
View File
@@ -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>
+8 -1
View File
@@ -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':
+17
View File
@@ -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;