From a48c2badd96ebddbb4d003934f446e5f1948afcf Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Sat, 13 Jun 2026 18:37:51 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=84=20style:=20improve=20shared=20Line?= =?UTF-8?q?ar=20tool=20rendering=20(#15769)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/client/Inspector/LinearMcp.tsx | 14 +- .../src/client/Inspector/index.ts | 10 +- .../src/client/Inspector/linearMcpLabels.ts | 6 +- .../src/client/Render/LinearMcp.tsx | 23 ++ .../src/client/Render/index.ts | 33 +- .../builtin-tools/src/codex/McpToolRender.tsx | 41 ++- packages/shared-tool-ui/package.json | 1 + .../src/Inspector/Linear/index.ts | 2 + .../src/Inspector/Linear/labels.test.ts | 24 ++ .../src/Inspector/Linear/labels.ts | 25 +- .../src/Render/Linear/index.tsx | 302 +++++++++++++++++ .../src/Render/Linear/utils.test.ts | 71 ++++ .../shared-tool-ui/src/Render/Linear/utils.ts | 308 ++++++++++++++++++ packages/shared-tool-ui/src/Render/index.ts | 1 + .../AssistantGroup/toolDisplayNames.test.ts | 5 + .../RenderGallery/fixtures/claude-code.ts | 28 ++ .../DevPanel/RenderGallery/fixtures/codex.ts | 81 ++++- src/store/tool/builtinToolRegistry.test.ts | 12 + 18 files changed, 955 insertions(+), 32 deletions(-) create mode 100644 packages/builtin-tool-claude-code/src/client/Render/LinearMcp.tsx create mode 100644 packages/shared-tool-ui/src/Inspector/Linear/labels.test.ts create mode 100644 packages/shared-tool-ui/src/Render/Linear/index.tsx create mode 100644 packages/shared-tool-ui/src/Render/Linear/utils.test.ts create mode 100644 packages/shared-tool-ui/src/Render/Linear/utils.ts diff --git a/packages/builtin-tool-claude-code/src/client/Inspector/LinearMcp.tsx b/packages/builtin-tool-claude-code/src/client/Inspector/LinearMcp.tsx index 4641effa63..3dd27d988b 100644 --- a/packages/builtin-tool-claude-code/src/client/Inspector/LinearMcp.tsx +++ b/packages/builtin-tool-claude-code/src/client/Inspector/LinearMcp.tsx @@ -3,11 +3,21 @@ import { LinearInspector } from '@lobechat/shared-tool-ui/inspectors'; import type { BuiltinInspector } from '@lobechat/types'; -import { LINEAR_MCP_PREFIX, LINEAR_MCP_TOOL_NAMES } from './linearMcpLabels'; +import { isLinearMcpApiName, LINEAR_MCP_PREFIX, LINEAR_MCP_TOOL_NAMES } from './linearMcpLabels'; // The shared `LinearInspector` already strips `LINEAR_MCP_PREFIX` when // parsing, so we just register it under every MCP-prefixed wire name CC // emits for the claude.ai Linear server. -export const LinearMcpInspectors: Record = Object.fromEntries( +const FixedLinearMcpInspectors: Record = Object.fromEntries( LINEAR_MCP_TOOL_NAMES.map((tool) => [`${LINEAR_MCP_PREFIX}${tool}`, LinearInspector]), ); + +export const LinearMcpInspectors: Record = new Proxy( + FixedLinearMcpInspectors, + { + get: (target, prop) => { + if (typeof prop !== 'string') return undefined; + return target[prop] || (isLinearMcpApiName(prop) ? LinearInspector : undefined); + }, + }, +); diff --git a/packages/builtin-tool-claude-code/src/client/Inspector/index.ts b/packages/builtin-tool-claude-code/src/client/Inspector/index.ts index 02852ab4bd..28181420c4 100644 --- a/packages/builtin-tool-claude-code/src/client/Inspector/index.ts +++ b/packages/builtin-tool-claude-code/src/client/Inspector/index.ts @@ -5,6 +5,7 @@ import { createGrepContentInspector, createRunCommandInspector, } from '@lobechat/shared-tool-ui/inspectors'; +import type { BuiltinInspector } from '@lobechat/types'; import { ClaudeCodeApiName } from '../../types'; import { AgentInspector } from './Agent'; @@ -33,7 +34,7 @@ import { WriteInspector } from './Write'; // Bash / Glob / Grep can use the shared factories directly — Glob / Grep only // need `pattern`. Edit / Read / Write need arg mapping (or synthesized plugin // state for diff stats), so they live in their own sibling files. -export const ClaudeCodeInspectors = { +const FixedClaudeCodeInspectors = { [ClaudeCodeApiName.Agent]: AgentInspector, [ClaudeCodeApiName.AskUserQuestion]: AskUserQuestionInspector, [ClaudeCodeApiName.Bash]: createRunCommandInspector(ClaudeCodeApiName.Bash), @@ -67,3 +68,10 @@ export const ClaudeCodeInspectors = { [ClaudeCodeApiName.Write]: WriteInspector, ...LinearMcpInspectors, }; + +export const ClaudeCodeInspectors = new Proxy(FixedClaudeCodeInspectors, { + get: (target, prop) => { + if (typeof prop !== 'string') return undefined; + return prop in target ? target[prop as keyof typeof target] : LinearMcpInspectors[prop]; + }, +}) as unknown as Record; diff --git a/packages/builtin-tool-claude-code/src/client/Inspector/linearMcpLabels.ts b/packages/builtin-tool-claude-code/src/client/Inspector/linearMcpLabels.ts index 964fb8e084..1b20fffa2a 100644 --- a/packages/builtin-tool-claude-code/src/client/Inspector/linearMcpLabels.ts +++ b/packages/builtin-tool-claude-code/src/client/Inspector/linearMcpLabels.ts @@ -5,7 +5,7 @@ // reason the labels file lives separately from `LinearMcp.tsx`. import { - LINEAR_MCP_PREFIX, + isLinearMcpApiName, LINEAR_TOOL_NAMES, parseToolName, staticLabelFor, @@ -13,6 +13,8 @@ import { export { capitalize, + getLinearToolSuffix, + isLinearMcpApiName, LINEAR_MCP_PREFIX, type ParsedTool, parseToolName, @@ -24,6 +26,6 @@ export { export const LINEAR_MCP_TOOL_NAMES = LINEAR_TOOL_NAMES; export const formatLinearMcpShortLabel = (apiName: string): string | null => { - if (!apiName.startsWith(LINEAR_MCP_PREFIX)) return null; + if (!isLinearMcpApiName(apiName)) return null; return `Linear · ${staticLabelFor(parseToolName(apiName))}`; }; diff --git a/packages/builtin-tool-claude-code/src/client/Render/LinearMcp.tsx b/packages/builtin-tool-claude-code/src/client/Render/LinearMcp.tsx new file mode 100644 index 0000000000..b523c9f258 --- /dev/null +++ b/packages/builtin-tool-claude-code/src/client/Render/LinearMcp.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { LinearRender } from '@lobechat/shared-tool-ui/renders'; +import type { BuiltinRender } from '@lobechat/types'; + +import { + isLinearMcpApiName, + LINEAR_MCP_PREFIX, + LINEAR_MCP_TOOL_NAMES, +} from '../Inspector/linearMcpLabels'; + +const SharedLinearRender = LinearRender as unknown as BuiltinRender; + +const FixedLinearMcpRenders: Record = Object.fromEntries( + LINEAR_MCP_TOOL_NAMES.map((tool) => [`${LINEAR_MCP_PREFIX}${tool}`, SharedLinearRender]), +); + +export const LinearMcpRenders: Record = new Proxy(FixedLinearMcpRenders, { + get: (target, prop) => { + if (typeof prop !== 'string') return undefined; + return target[prop] || (isLinearMcpApiName(prop) ? SharedLinearRender : undefined); + }, +}); diff --git a/packages/builtin-tool-claude-code/src/client/Render/index.ts b/packages/builtin-tool-claude-code/src/client/Render/index.ts index a1e7e00288..cec9156130 100644 --- a/packages/builtin-tool-claude-code/src/client/Render/index.ts +++ b/packages/builtin-tool-claude-code/src/client/Render/index.ts @@ -1,12 +1,18 @@ import { RunCommandRender } from '@lobechat/shared-tool-ui/renders'; -import type { RenderDisplayControl } from '@lobechat/types'; +import type { BuiltinRender, RenderDisplayControl } from '@lobechat/types'; import { ClaudeCodeApiName } from '../../types'; +import { + isLinearMcpApiName, + LINEAR_MCP_PREFIX, + LINEAR_MCP_TOOL_NAMES, +} from '../Inspector/linearMcpLabels'; import Agent from './Agent'; import AskUserQuestion from './AskUserQuestion'; import Edit from './Edit'; import Glob from './Glob'; import Grep from './Grep'; +import { LinearMcpRenders } from './LinearMcp'; import Read from './Read'; import Skill from './Skill'; import Task from './Task'; @@ -21,7 +27,7 @@ import Write from './Write'; * Maps CC tool names (the `name` on Anthropic `tool_use` blocks) to dedicated * visualizations, keyed so `getBuiltinRender('claude-code', apiName)` resolves. */ -export const ClaudeCodeRenders = { +const FixedClaudeCodeRenders = { [ClaudeCodeApiName.Agent]: Agent, [ClaudeCodeApiName.AskUserQuestion]: AskUserQuestion, // RunCommand already renders `args.command` + combined output the way CC emits — @@ -45,8 +51,16 @@ export const ClaudeCodeRenders = { [ClaudeCodeApiName.WebFetch]: WebFetch, [ClaudeCodeApiName.WebSearch]: WebSearch, [ClaudeCodeApiName.Write]: Write, + ...LinearMcpRenders, }; +export const ClaudeCodeRenders = new Proxy(FixedClaudeCodeRenders, { + get: (target, prop) => { + if (typeof prop !== 'string') return undefined; + return prop in target ? target[prop as keyof typeof target] : LinearMcpRenders[prop]; + }, +}) as unknown as Record; + /** * Per-APIName default display control for CC tool renders. * @@ -55,10 +69,23 @@ export const ClaudeCodeRenders = { * can't reach these. The builtin-tools aggregator exposes this map via * `getBuiltinRenderDisplayControl` as a fallback. */ -export const ClaudeCodeRenderDisplayControls: Record = { +const FixedClaudeCodeRenderDisplayControls: Record = { [ClaudeCodeApiName.Edit]: 'expand', [ClaudeCodeApiName.TaskList]: 'expand', [ClaudeCodeApiName.TaskUpdate]: 'expand', [ClaudeCodeApiName.TodoWrite]: 'expand', [ClaudeCodeApiName.Write]: 'expand', + ...Object.fromEntries( + LINEAR_MCP_TOOL_NAMES.map((tool) => [`${LINEAR_MCP_PREFIX}${tool}`, 'expand']), + ), }; + +export const ClaudeCodeRenderDisplayControls: Record = new Proxy( + FixedClaudeCodeRenderDisplayControls, + { + get: (target, prop) => { + if (typeof prop !== 'string') return undefined; + return target[prop] || (isLinearMcpApiName(prop) ? 'expand' : undefined); + }, + }, +); diff --git a/packages/builtin-tools/src/codex/McpToolRender.tsx b/packages/builtin-tools/src/codex/McpToolRender.tsx index 2a001cbd8d..deef446ac0 100644 --- a/packages/builtin-tools/src/codex/McpToolRender.tsx +++ b/packages/builtin-tools/src/codex/McpToolRender.tsx @@ -1,8 +1,11 @@ 'use client'; +import { LINEAR_TOOL_NAMES } from '@lobechat/shared-tool-ui/inspectors'; +import { LinearRender } from '@lobechat/shared-tool-ui/renders'; import type { BuiltinRenderProps } from '@lobechat/types'; import { Flexbox, Highlighter, Text } from '@lobehub/ui'; import { createStaticStyles, cssVar } from 'antd-style'; +import type { ComponentType } from 'react'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -10,9 +13,12 @@ import type { CodexMcpToolArgs, CodexMcpToolState } from './mcpToolUtils'; import { formatMcpInput, formatMcpOutput, + getCodexLinearMcpApiName, getMcpErrorText, getMcpInput, + getMcpInputRecord, getMcpResultText, + getMcpServer, getMcpToolName, } from './mcpToolUtils'; @@ -24,13 +30,42 @@ const styles = createStaticStyles(({ css, cssVar }) => ({ `, })); +const LINEAR_TOOL_NAME_SET = new Set([...LINEAR_TOOL_NAMES, 'fetch', 'search']); +const SharedLinearRender = LinearRender as ComponentType< + BuiltinRenderProps, unknown, string> +>; + const McpToolRender = memo>( - ({ args, content, pluginState }) => { + ({ args, content, messageId, pluginState, toolCallId }) => { const { t } = useTranslation('plugin'); + const server = getMcpServer(args, pluginState); const toolName = getMcpToolName(args, pluginState); - const input = formatMcpInput(getMcpInput(args, pluginState), toolName); - const output = formatMcpOutput(getMcpResultText(content, pluginState, args), toolName); + const inputRecord = getMcpInputRecord(args, pluginState); + const resultText = getMcpResultText(content, pluginState, args); const error = getMcpErrorText(pluginState, args); + const linearApiName = getCodexLinearMcpApiName({ + input: inputRecord, + server, + toolName, + }); + + if (LINEAR_TOOL_NAME_SET.has(linearApiName)) { + return ( + + ); + } + + const input = formatMcpInput(getMcpInput(args, pluginState), toolName); + const output = formatMcpOutput(resultText, toolName); if (!input && !output && !error) return null; diff --git a/packages/shared-tool-ui/package.json b/packages/shared-tool-ui/package.json index 6fbb8e635e..ab63988389 100644 --- a/packages/shared-tool-ui/package.json +++ b/packages/shared-tool-ui/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@lobechat/tool-runtime": "workspace:*", + "@lobechat/utils": "workspace:*", "anser": "^2.3.5" }, "devDependencies": { diff --git a/packages/shared-tool-ui/src/Inspector/Linear/index.ts b/packages/shared-tool-ui/src/Inspector/Linear/index.ts index 0c2f4d7a50..39d0e9cb5f 100644 --- a/packages/shared-tool-ui/src/Inspector/Linear/index.ts +++ b/packages/shared-tool-ui/src/Inspector/Linear/index.ts @@ -1,6 +1,8 @@ export { LinearInspector } from './Inspector'; export { capitalize, + getLinearToolSuffix, + isLinearMcpApiName, LINEAR_MCP_PREFIX, LINEAR_TOOL_NAMES, type ParsedTool, diff --git a/packages/shared-tool-ui/src/Inspector/Linear/labels.test.ts b/packages/shared-tool-ui/src/Inspector/Linear/labels.test.ts new file mode 100644 index 0000000000..c75c2ddaa5 --- /dev/null +++ b/packages/shared-tool-ui/src/Inspector/Linear/labels.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { getLinearToolSuffix, isLinearMcpApiName, parseToolName } from './labels'; + +describe('Linear label helpers', () => { + it('normalizes Claude Code and generic Linear MCP tool names', () => { + expect(getLinearToolSuffix('mcp__claude_ai_Linear__get_issue')).toBe('get_issue'); + expect(getLinearToolSuffix('mcp__linear-server__save_issue')).toBe('save_issue'); + expect(getLinearToolSuffix('mcp__linear__list_issues')).toBe('list_issues'); + }); + + it('detects only MCP names backed by a Linear server segment', () => { + expect(isLinearMcpApiName('mcp__linear-server__get_issue')).toBe(true); + expect(isLinearMcpApiName('mcp__github__get_issue')).toBe(false); + expect(isLinearMcpApiName('get_issue')).toBe(false); + }); + + it('parses generic Linear MCP suffixes into verb and noun labels', () => { + expect(parseToolName('mcp__linear-server__save_issue')).toEqual({ + noun: 'issue', + verb: 'save', + }); + }); +}); diff --git a/packages/shared-tool-ui/src/Inspector/Linear/labels.ts b/packages/shared-tool-ui/src/Inspector/Linear/labels.ts index e8f9e4cb57..8bbc8fc891 100644 --- a/packages/shared-tool-ui/src/Inspector/Linear/labels.ts +++ b/packages/shared-tool-ui/src/Inspector/Linear/labels.ts @@ -78,10 +78,29 @@ export interface ParsedTool { verb: 'get' | 'list' | 'save' | 'create' | 'delete' | 'search' | 'extract' | 'prepare' | 'other'; } +const isLinearMcpServerSegment = (segment: string) => + segment + .toLowerCase() + .split(/[^a-z0-9]+/u) + .includes('linear'); + +export const getLinearToolSuffix = (apiName: string): string => { + if (apiName.startsWith(LINEAR_MCP_PREFIX)) return apiName.slice(LINEAR_MCP_PREFIX.length); + + const parts = apiName.split('__'); + if (parts.length >= 3 && parts[0] === 'mcp') { + const serverParts = parts.slice(1, -1); + if (serverParts.some(isLinearMcpServerSegment)) return parts.at(-1) || apiName; + } + + return apiName; +}; + +export const isLinearMcpApiName = (apiName: string): boolean => + getLinearToolSuffix(apiName) !== apiName; + export const parseToolName = (apiName: string): ParsedTool => { - const suffix = apiName.startsWith(LINEAR_MCP_PREFIX) - ? apiName.slice(LINEAR_MCP_PREFIX.length) - : apiName; + const suffix = getLinearToolSuffix(apiName); if (suffix === 'extract_images') return { noun: 'images', verb: 'extract' }; if (suffix === 'prepare_attachment_upload') return { noun: 'attachment upload', verb: 'prepare' }; diff --git a/packages/shared-tool-ui/src/Render/Linear/index.tsx b/packages/shared-tool-ui/src/Render/Linear/index.tsx new file mode 100644 index 0000000000..8e0f210add --- /dev/null +++ b/packages/shared-tool-ui/src/Render/Linear/index.tsx @@ -0,0 +1,302 @@ +'use client'; + +import type { BuiltinRenderProps } from '@lobechat/types'; +import { Block, Flexbox, Highlighter, Icon, Markdown, Tag, Text } from '@lobehub/ui'; +import { createStaticStyles } from 'antd-style'; +import { ExternalLink, Link2 } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { memo, useMemo } from 'react'; + +import { + buildLinearRenderModel, + type LinearEntity, + type LinearField, + type LinearLink, +} from './utils'; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + container: css` + overflow: hidden; + min-width: 0; + `, + description: css` + overflow: auto; + + max-height: 180px; + padding-block: 8px; + padding-inline: 10px; + border: 1px solid ${cssVar.colorBorderSecondary}; + border-radius: 6px; + + background: ${cssVar.colorFillQuaternary}; + `, + entityHeader: css` + display: flex; + gap: 8px; + align-items: flex-start; + justify-content: space-between; + + min-width: 0; + `, + fieldGrid: css` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 6px; + `, + fieldItem: css` + overflow: hidden; + + min-width: 0; + padding-block: 6px; + padding-inline: 8px; + border-radius: 6px; + + background: ${cssVar.colorFillQuaternary}; + `, + fieldLabel: css` + display: block; + margin-block-end: 2px; + font-size: 11px; + color: ${cssVar.colorTextTertiary}; + `, + fieldValue: css` + overflow: hidden; + display: block; + + min-width: 0; + + font-size: 12px; + color: ${cssVar.colorText}; + text-overflow: ellipsis; + white-space: nowrap; + `, + linkRow: css` + overflow: hidden; + display: flex; + gap: 8px; + align-items: center; + + min-width: 0; + padding-block: 6px; + padding-inline: 8px; + border-radius: 6px; + + color: ${cssVar.colorText}; + + background: ${cssVar.colorFillQuaternary}; + + &:hover { + color: ${cssVar.colorLink}; + background: ${cssVar.colorFillTertiary}; + } + `, + linkText: css` + overflow: hidden; + min-width: 0; + `, + rawDetails: css` + font-size: 12px; + color: ${cssVar.colorTextSecondary}; + + summary { + cursor: pointer; + width: fit-content; + margin-block-end: 6px; + } + `, + sectionLabel: css` + font-size: 12px; + color: ${cssVar.colorTextSecondary}; + `, + titleLink: css` + display: inline-flex; + gap: 4px; + align-items: center; + + min-width: 0; + + color: inherit; + + &:hover { + color: ${cssVar.colorLink}; + } + `, +})); + +const hasItems = (items: T[]) => items.length > 0; + +const Section = memo<{ children: ReactNode; title: string }>(({ children, title }) => ( + + {title} + {children} + +)); +Section.displayName = 'LinearRenderSection'; + +const FieldGrid = memo<{ fields: LinearField[] }>(({ fields }) => { + if (!hasItems(fields)) return null; + + return ( +
+ {fields.map((field) => ( +
+ {field.label} + + {field.value} + +
+ ))} +
+ ); +}); +FieldGrid.displayName = 'LinearRenderFieldGrid'; + +const LinkList = memo<{ links: LinearLink[] }>(({ links }) => { + if (!hasItems(links)) return null; + + return ( + + {links.map((link) => ( + + + + {link.title} + + + + ))} + + ); +}); +LinkList.displayName = 'LinearRenderLinkList'; + +const EntityCard = memo<{ entity: LinearEntity }>(({ entity }) => { + const title = entity.title || entity.id || 'Linear item'; + + return ( + +
+ + {entity.url ? ( + + + {title} + + + + ) : ( + + {title} + + )} + + {entity.id && {entity.id}} + {entity.state && ( + + {entity.state} + + )} + + +
+ + {entity.description && ( +
+ + {entity.description} + +
+ )} + +
+ ); +}); +EntityCard.displayName = 'LinearRenderEntityCard'; + +const LinearRender = memo, unknown, unknown>>( + ({ apiName, args, content, pluginError }) => { + const model = useMemo( + () => buildLinearRenderModel({ apiName, args, content, pluginError }), + [apiName, args, content, pluginError], + ); + const hasRequest = hasItems(model.requestFields) || hasItems(model.requestLinks); + const hasResult = + hasItems(model.resultEntities) || Boolean(model.resultText) || Boolean(model.rawResultJson); + + if (!hasRequest && !hasResult && !model.errorText) return null; + + return ( + + {hasRequest && ( +
+ + + + +
+ )} + {hasItems(model.resultEntities) && ( +
+ + {model.resultEntities.map((entity, index) => ( + + ))} + +
+ )} + {model.resultText && ( +
+ + {model.resultText} + +
+ )} + {model.rawResultJson && ( +
+ Raw result + + {model.rawResultJson} + +
+ )} + {model.errorText && ( +
+ + {model.errorText} + +
+ )} +
+ ); + }, +); + +LinearRender.displayName = 'LinearRender'; + +export default LinearRender; diff --git a/packages/shared-tool-ui/src/Render/Linear/utils.test.ts b/packages/shared-tool-ui/src/Render/Linear/utils.test.ts new file mode 100644 index 0000000000..95f0d46dc1 --- /dev/null +++ b/packages/shared-tool-ui/src/Render/Linear/utils.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { buildLinearRenderModel } from './utils'; + +describe('buildLinearRenderModel', () => { + it('summarizes Linear update issue input and result without exposing raw JSON first', () => { + const model = buildLinearRenderModel({ + apiName: 'save_issue', + args: { + id: 'LOBE-10205', + links: [ + { + title: 'PR #15766: refactor(chat): unify agent run lifecycle', + url: 'https://github.com/lobehub/lobehub/pull/15766', + }, + ], + state: 'In Review', + }, + content: JSON.stringify({ + description: '## 背景\n\n统一三种客户端 Agent Runtime 的 run 生命周期 hooks。', + id: 'LOBE-10205', + links: [ + { + title: 'PR #15766: refactor(chat): unify agent run lifecycle', + url: 'https://github.com/lobehub/lobehub/pull/15766', + }, + ], + state: { name: 'In Review' }, + title: '统一三种客户端 Agent Runtime 的 run 生命周期 hooks', + url: 'https://linear.app/lobehub/issue/LOBE-10205', + }), + }); + + expect(model.actionLabel).toBe('Save issue'); + expect(model.requestFields).toEqual([ + { key: 'id', label: 'ID', value: 'LOBE-10205' }, + { key: 'state', label: 'State', value: 'In Review' }, + ]); + expect(model.requestLinks).toHaveLength(1); + expect(model.resultEntities).toHaveLength(1); + expect(model.resultEntities[0]).toMatchObject({ + description: '## 背景\n\n统一三种客户端 Agent Runtime 的 run 生命周期 hooks。', + id: 'LOBE-10205', + state: 'In Review', + title: '统一三种客户端 Agent Runtime 的 run 生命周期 hooks', + url: 'https://linear.app/lobehub/issue/LOBE-10205', + }); + expect(model.rawResultJson).toBeUndefined(); + }); + + it('normalizes Codex Apps entity-prefixed ids in request fields', () => { + const model = buildLinearRenderModel({ + apiName: 'get_issue', + args: { id: 'issue:LOBE-10205' }, + content: '{"title":"Resume error on topic switch"}', + }); + + expect(model.requestFields).toContainEqual({ key: 'id', label: 'ID', value: 'LOBE-10205' }); + expect(model.resultEntities[0]).toMatchObject({ title: 'Resume error on topic switch' }); + }); + + it('keeps non-JSON result text as a readable fallback', () => { + const model = buildLinearRenderModel({ + apiName: 'search', + args: { query: 'agent runtime' }, + content: 'No matching Linear issues found.', + }); + + expect(model.resultText).toBe('No matching Linear issues found.'); + }); +}); diff --git a/packages/shared-tool-ui/src/Render/Linear/utils.ts b/packages/shared-tool-ui/src/Render/Linear/utils.ts new file mode 100644 index 0000000000..fd35e0a5ec --- /dev/null +++ b/packages/shared-tool-ui/src/Render/Linear/utils.ts @@ -0,0 +1,308 @@ +import { isRecord } from '@lobechat/utils/object'; +import { safeParseJSON } from '@lobechat/utils/safeParseJSON'; + +import { parseToolName, staticLabelFor } from '../../Inspector/Linear/labels'; + +const PRIORITY_LABEL: Record = { + 0: 'None', + 1: 'Urgent', + 2: 'High', + 3: 'Medium', + 4: 'Low', +}; + +const ENTITY_ID_PREFIX = /^(issue|project|document|initiative|milestone|team|user|cycle):(.+)$/iu; +const FIELD_KEYS = [ + 'id', + 'title', + 'name', + 'state', + 'status', + 'team', + 'project', + 'assignee', + 'cycle', + 'milestone', + 'priority', + 'parentId', + 'query', + 'url', +] as const; +const ENTITY_FIELD_KEYS = [ + 'state', + 'status', + 'team', + 'project', + 'assignee', + 'cycle', + 'milestone', + 'priority', + 'parentId', + 'createdAt', + 'updatedAt', +] as const; +const RESULT_ARRAY_KEYS = [ + 'issues', + 'items', + 'nodes', + 'results', + 'documents', + 'projects', + 'comments', + 'users', + 'teams', +] as const; + +export interface LinearField { + key: string; + label: string; + value: string; +} + +export interface LinearLink { + title: string; + url: string; +} + +export interface LinearEntity { + description?: string; + fields: LinearField[]; + id?: string; + links: LinearLink[]; + state?: string; + title?: string; + url?: string; +} + +export interface LinearRenderModel { + actionLabel: string; + errorText?: string; + rawResultJson?: string; + requestFields: LinearField[]; + requestLinks: LinearLink[]; + resultEntities: LinearEntity[]; + resultText?: string; +} + +const toLabel = (key: string) => + key + .replaceAll(/([A-Z])/g, ' $1') + .replace(/^./u, (char) => char.toUpperCase()) + .trim(); + +const normalizeId = (value: string) => value.replace(ENTITY_ID_PREFIX, '$2'); + +const trimString = (value: unknown): string | undefined => { + if (typeof value !== 'string') return; + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +}; + +const stringifyUnknown = (value: unknown): string => { + if (typeof value === 'string') return value.trim(); + if (value === undefined || value === null) return ''; + + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +}; + +const readDisplayString = (value: unknown, key?: string): string | undefined => { + const stringValue = trimString(value); + if (stringValue) return key === 'id' ? normalizeId(stringValue) : stringValue; + + if (typeof value === 'number') { + if (key === 'priority') return PRIORITY_LABEL[value] ?? String(value); + return String(value); + } + + if (typeof value === 'boolean') return value ? 'Yes' : 'No'; + + if (isRecord(value)) { + for (const candidate of ['name', 'title', 'displayName', 'identifier', 'id']) { + const nested = readDisplayString(value[candidate], candidate); + if (nested) return nested; + } + } +}; + +const pickField = (record: Record, key: string): LinearField | undefined => { + const value = readDisplayString(record[key], key); + if (!value) return; + + return { + key, + label: key === 'id' ? 'ID' : key === 'url' ? 'URL' : toLabel(key), + value, + }; +}; + +const collectFields = ( + record: Record, + keys: readonly string[], +): LinearField[] => + keys.map((key) => pickField(record, key)).filter((field): field is LinearField => Boolean(field)); + +export const getLinearRequestFields = (args: unknown): LinearField[] => { + if (!isRecord(args)) return []; + + return collectFields(args, FIELD_KEYS); +}; + +const getTextFromContentItem = (item: unknown): string => { + if (typeof item === 'string') return item; + if (!isRecord(item)) return stringifyUnknown(item); + + return trimString(item.text) || trimString(item.content) || stringifyUnknown(item); +}; + +const parseJsonString = (value: string): unknown => safeParseJSON(value); + +const parseContentText = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + + const trimmed = value.trim(); + if (!trimmed) return undefined; + + return parseJsonString(trimmed) ?? trimmed; +}; + +const unwrapResultEnvelope = (value: unknown): unknown => { + if (!isRecord(value)) return value; + + if ('Ok' in value) return value.Ok; + if ('Err' in value) return value.Err; + if ('ok' in value) return value.ok; + if ('error' in value && Object.keys(value).length === 1) return value.error; + + return value; +}; + +const parseResultContent = (content: unknown): unknown => { + const parsed = unwrapResultEnvelope(parseContentText(content)); + + if (Array.isArray(parsed)) { + const joined = parsed.map(getTextFromContentItem).filter(Boolean).join('\n\n'); + return parseContentText(joined) ?? parsed; + } + + if (isRecord(parsed) && Array.isArray(parsed.content)) { + const joined = parsed.content.map(getTextFromContentItem).filter(Boolean).join('\n\n'); + return parseContentText(joined) ?? parsed; + } + + return parsed; +}; + +const extractUrl = (record: Record) => + readDisplayString(record.url, 'url') || readDisplayString(record.webUrl, 'url'); + +export const getLinearLinks = (value: unknown): LinearLink[] => { + if (!Array.isArray(value)) return []; + + return value + .map((item) => { + if (!isRecord(item)) return; + + const url = extractUrl(item); + if (!url) return; + + return { + title: readDisplayString(item.title) || readDisplayString(item.name) || url, + url, + }; + }) + .filter((link): link is LinearLink => Boolean(link)); +}; + +const buildEntity = (record: Record): LinearEntity | undefined => { + const id = readDisplayString(record.id, 'id') || readDisplayString(record.identifier, 'id'); + const title = + readDisplayString(record.title) || + readDisplayString(record.name) || + readDisplayString(record.subject); + const url = extractUrl(record); + const state = + readDisplayString(record.state, 'state') || readDisplayString(record.status, 'status'); + const description = + readDisplayString(record.description) || + readDisplayString(record.body) || + readDisplayString(record.content); + const fields = collectFields(record, ENTITY_FIELD_KEYS).filter( + (field) => field.key !== 'state' || field.value !== state, + ); + const links = getLinearLinks(record.links); + + if (!id && !title && !url && !state && !description && fields.length === 0 && links.length === 0) + return; + + return { + description, + fields, + id, + links, + state, + title, + url, + }; +}; + +const extractResultRecords = (value: unknown): Record[] => { + if (Array.isArray(value)) return value.filter(isRecord); + if (!isRecord(value)) return []; + + for (const key of RESULT_ARRAY_KEYS) { + const nested = value[key]; + if (Array.isArray(nested)) return nested.filter(isRecord); + } + + return [value]; +}; + +const getErrorText = (error: unknown): string | undefined => { + if (!error) return; + if (typeof error === 'string') return error.trim() || undefined; + if (isRecord(error)) { + return ( + readDisplayString(error.message) || readDisplayString(error.error) || stringifyUnknown(error) + ); + } + + return stringifyUnknown(error); +}; + +export const buildLinearRenderModel = ({ + apiName, + args, + content, + pluginError, +}: { + apiName?: string; + args: unknown; + content: unknown; + pluginError?: unknown; +}): LinearRenderModel => { + const parsedTool = parseToolName(apiName || ''); + const result = parseResultContent(content); + const resultEntities = extractResultRecords(result) + .map(buildEntity) + .filter((entity): entity is LinearEntity => Boolean(entity)); + const resultText = typeof result === 'string' ? result : undefined; + const rawResultJson = + result !== undefined && typeof result !== 'string' && resultEntities.length === 0 + ? stringifyUnknown(result) + : undefined; + + return { + actionLabel: staticLabelFor(parsedTool), + errorText: getErrorText(pluginError), + requestFields: getLinearRequestFields(args), + requestLinks: isRecord(args) ? getLinearLinks(args.links) : [], + resultEntities, + resultText, + rawResultJson, + }; +}; diff --git a/packages/shared-tool-ui/src/Render/index.ts b/packages/shared-tool-ui/src/Render/index.ts index 5ff72a984b..61914d2921 100644 --- a/packages/shared-tool-ui/src/Render/index.ts +++ b/packages/shared-tool-ui/src/Render/index.ts @@ -1 +1,2 @@ +export { default as LinearRender } from './Linear'; export { default as RunCommandRender } from './RunCommand'; diff --git a/src/features/Conversation/Messages/AssistantGroup/toolDisplayNames.test.ts b/src/features/Conversation/Messages/AssistantGroup/toolDisplayNames.test.ts index fdffc4ef99..df15594cbe 100644 --- a/src/features/Conversation/Messages/AssistantGroup/toolDisplayNames.test.ts +++ b/src/features/Conversation/Messages/AssistantGroup/toolDisplayNames.test.ts @@ -48,6 +48,11 @@ describe('tool display names', () => { expect(summary).not.toContain('Mcp_tool_call'); expect(summary).not.toContain('Web_search'); }); + + it('uses friendly labels for Linear MCP tool names', () => { + expect(getToolDisplayName('mcp__claude_ai_Linear__save_issue')).toBe('Linear · Save issue'); + expect(getToolDisplayName('mcp__linear-server__get_issue')).toBe('Linear · Get issue'); + }); }); describe('shapeProseForWorkflowHeadline', () => { diff --git a/src/features/DevPanel/RenderGallery/fixtures/claude-code.ts b/src/features/DevPanel/RenderGallery/fixtures/claude-code.ts index 1cf34c6770..fdc103fc9c 100644 --- a/src/features/DevPanel/RenderGallery/fixtures/claude-code.ts +++ b/src/features/DevPanel/RenderGallery/fixtures/claude-code.ts @@ -4,6 +4,22 @@ import { ClaudeCodeIdentifier } from '@lobechat/builtin-tool-claude-code/client' import { defineFixtures, single, variants } from './_helpers'; +const linearIssueApiName = 'mcp__claude_ai_Linear__save_issue'; +const linearIssueResult = { + description: + '## 背景\n\n当前客户端侧有三种 agent runtime 路径,它们都在处理同一类 agent run 生命周期,但生命周期控制点不一致。\n\n## 目标\n\n建立一套共享的 post-complete hooks,让 queue message、topic title、Agent Signal、unread completion 和 notification 都通过同一入口收敛。', + id: 'LOBE-10205', + links: [ + { + title: 'PR #15766: refactor(chat): unify agent run lifecycle', + url: 'https://github.com/lobehub/lobehub/pull/15766', + }, + ], + state: { name: 'In Review' }, + title: '统一三种客户端 Agent Runtime 的 run 生命周期 hooks', + url: 'https://linear.app/lobehub/issue/LOBE-10205', +}; + export default defineFixtures({ identifier: ClaudeCodeIdentifier, meta: { @@ -87,6 +103,10 @@ export default defineFixtures({ description: 'Write a new file.', name: 'Write', }, + { + description: 'Update a Linear issue through MCP.', + name: linearIssueApiName, + }, ], fixtures: { Agent: single({ @@ -396,5 +416,13 @@ export default defineFixtures({ file_path: 'src/routes/(main)/devtools/featureFlag.ts', }, }), + [linearIssueApiName]: single({ + args: { + id: 'LOBE-10205', + links: linearIssueResult.links, + state: 'In Review', + }, + content: JSON.stringify(linearIssueResult), + }), }, }); diff --git a/src/features/DevPanel/RenderGallery/fixtures/codex.ts b/src/features/DevPanel/RenderGallery/fixtures/codex.ts index edff2dd032..a213219e4e 100644 --- a/src/features/DevPanel/RenderGallery/fixtures/codex.ts +++ b/src/features/DevPanel/RenderGallery/fixtures/codex.ts @@ -1,6 +1,6 @@ 'use client'; -import { defineFixtures, single } from './_helpers'; +import { defineFixtures, single, variants } from './_helpers'; const addedFileDiff = `diff --git a/src/routes/(main)/devtools/index.tsx b/src/routes/(main)/devtools/index.tsx --- /dev/null @@ -21,6 +21,21 @@ const modifiedRegistryDiff = `diff --git a/packages/builtin-tools/src/renders.ts }; `; +const linearIssueResult = { + description: + '## 背景\n\n当前客户端侧有三种 agent runtime 路径,它们都在处理同一类 agent run 生命周期,但生命周期控制点不一致。\n\n## 目标\n\n建立一套共享的 post-complete hooks,让 queue message、topic title、Agent Signal、unread completion 和 notification 都通过同一入口收敛。', + id: 'LOBE-10205', + links: [ + { + title: 'PR #15766: refactor(chat): unify agent run lifecycle', + url: 'https://github.com/lobehub/lobehub/pull/15766', + }, + ], + state: { name: 'In Review' }, + title: '统一三种客户端 Agent Runtime 的 run 生命周期 hooks', + url: 'https://linear.app/lobehub/issue/LOBE-10205', +}; + export default defineFixtures({ identifier: 'codex', meta: { @@ -113,28 +128,58 @@ export default defineFixtures({ linesDeleted: 0, }, }), - mcp_tool_call: single({ - args: { - arguments: { - code: "const result = await import('./package.json', { with: { type: 'json' } });\nresult.default.name;", + mcp_tool_call: variants([ + { + args: { + arguments: { + code: "const result = await import('./package.json', { with: { type: 'json' } });\nresult.default.name;", + }, + server: 'node_repl', + tool: 'js', + }, + content: '@lobehub/desktop', + label: 'Node REPL', + pluginState: { + arguments: { + code: "const result = await import('./package.json', { with: { type: 'json' } });\nresult.default.name;", + }, + result: { + content: [{ text: '@lobehub/desktop', type: 'text' }], + isError: false, + }, + server: 'node_repl', + status: 'completed', + tool: 'js', }, - server: 'node_repl', - tool: 'js', }, - content: '@lobehub/desktop', - pluginState: { - arguments: { - code: "const result = await import('./package.json', { with: { type: 'json' } });\nresult.default.name;", + { + args: { + arguments: { + id: 'LOBE-10205', + links: linearIssueResult.links, + state: 'In Review', + }, + server: 'mcp__codex_apps__linear', + tool: 'linear_save_issue', }, - result: { - content: [{ text: '@lobehub/desktop', type: 'text' }], - isError: false, + content: JSON.stringify(linearIssueResult), + label: 'Linear update issue', + pluginState: { + arguments: { + id: 'LOBE-10205', + links: linearIssueResult.links, + state: 'In Review', + }, + result: { + content: [{ text: JSON.stringify(linearIssueResult), type: 'text' }], + isError: false, + }, + server: 'mcp__codex_apps__linear', + status: 'completed', + tool: 'linear_save_issue', }, - server: 'node_repl', - status: 'completed', - tool: 'js', }, - }), + ]), todo_list: single({ args: { items: [ diff --git a/src/store/tool/builtinToolRegistry.test.ts b/src/store/tool/builtinToolRegistry.test.ts index f525e0ae75..1e207a333a 100644 --- a/src/store/tool/builtinToolRegistry.test.ts +++ b/src/store/tool/builtinToolRegistry.test.ts @@ -1,4 +1,5 @@ import { WEB_ONBOARDING } from '@lobechat/builtin-agents'; +import { ClaudeCodeIdentifier as ClaudeCodeToolIdentifier } from '@lobechat/builtin-tool-claude-code/client'; import { GroupAgentBuilderApiName, GroupAgentBuilderIdentifier, @@ -12,7 +13,10 @@ import { WebOnboardingIdentifier, WebOnboardingManifest, } from '@lobechat/builtin-tool-web-onboarding'; +import { getBuiltinRenderDisplayControl } from '@lobechat/builtin-tools/displayControls'; import { builtinToolIdentifiers } from '@lobechat/builtin-tools/identifiers'; +import { getBuiltinInspector } from '@lobechat/builtin-tools/inspectors'; +import { getBuiltinRender } from '@lobechat/builtin-tools/renders'; import { describe, expect, it } from 'vitest'; describe('builtin tool registry', () => { @@ -36,6 +40,14 @@ describe('builtin tool registry', () => { expect(GroupAgentBuilderInspectors[GroupAgentBuilderApiName.createGroup]).toBeDefined(); }); + it('registers shared Linear MCP surfaces for Claude Code server variants', () => { + const apiName = 'mcp__linear-server__save_issue'; + + expect(getBuiltinInspector(ClaudeCodeToolIdentifier, apiName)).toBeDefined(); + expect(getBuiltinRender(ClaudeCodeToolIdentifier, apiName)).toBeDefined(); + expect(getBuiltinRenderDisplayControl(ClaudeCodeToolIdentifier, apiName)).toBe('expand'); + }); + it('includes user interaction and web onboarding in web onboarding runtime plugins', () => { const runtime = typeof WEB_ONBOARDING.runtime === 'function'