mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
💄 style: improve shared Linear tool rendering (#15769)
This commit is contained in:
@@ -3,11 +3,21 @@
|
|||||||
import { LinearInspector } from '@lobechat/shared-tool-ui/inspectors';
|
import { LinearInspector } from '@lobechat/shared-tool-ui/inspectors';
|
||||||
import type { BuiltinInspector } from '@lobechat/types';
|
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
|
// The shared `LinearInspector` already strips `LINEAR_MCP_PREFIX` when
|
||||||
// parsing, so we just register it under every MCP-prefixed wire name CC
|
// parsing, so we just register it under every MCP-prefixed wire name CC
|
||||||
// emits for the claude.ai Linear server.
|
// emits for the claude.ai Linear server.
|
||||||
export const LinearMcpInspectors: Record<string, BuiltinInspector> = Object.fromEntries(
|
const FixedLinearMcpInspectors: Record<string, BuiltinInspector> = Object.fromEntries(
|
||||||
LINEAR_MCP_TOOL_NAMES.map((tool) => [`${LINEAR_MCP_PREFIX}${tool}`, LinearInspector]),
|
LINEAR_MCP_TOOL_NAMES.map((tool) => [`${LINEAR_MCP_PREFIX}${tool}`, LinearInspector]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const LinearMcpInspectors: Record<string, BuiltinInspector> = new Proxy(
|
||||||
|
FixedLinearMcpInspectors,
|
||||||
|
{
|
||||||
|
get: (target, prop) => {
|
||||||
|
if (typeof prop !== 'string') return undefined;
|
||||||
|
return target[prop] || (isLinearMcpApiName(prop) ? LinearInspector : undefined);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
createGrepContentInspector,
|
createGrepContentInspector,
|
||||||
createRunCommandInspector,
|
createRunCommandInspector,
|
||||||
} from '@lobechat/shared-tool-ui/inspectors';
|
} from '@lobechat/shared-tool-ui/inspectors';
|
||||||
|
import type { BuiltinInspector } from '@lobechat/types';
|
||||||
|
|
||||||
import { ClaudeCodeApiName } from '../../types';
|
import { ClaudeCodeApiName } from '../../types';
|
||||||
import { AgentInspector } from './Agent';
|
import { AgentInspector } from './Agent';
|
||||||
@@ -33,7 +34,7 @@ import { WriteInspector } from './Write';
|
|||||||
// Bash / Glob / Grep can use the shared factories directly — Glob / Grep only
|
// Bash / Glob / Grep can use the shared factories directly — Glob / Grep only
|
||||||
// need `pattern`. Edit / Read / Write need arg mapping (or synthesized plugin
|
// need `pattern`. Edit / Read / Write need arg mapping (or synthesized plugin
|
||||||
// state for diff stats), so they live in their own sibling files.
|
// state for diff stats), so they live in their own sibling files.
|
||||||
export const ClaudeCodeInspectors = {
|
const FixedClaudeCodeInspectors = {
|
||||||
[ClaudeCodeApiName.Agent]: AgentInspector,
|
[ClaudeCodeApiName.Agent]: AgentInspector,
|
||||||
[ClaudeCodeApiName.AskUserQuestion]: AskUserQuestionInspector,
|
[ClaudeCodeApiName.AskUserQuestion]: AskUserQuestionInspector,
|
||||||
[ClaudeCodeApiName.Bash]: createRunCommandInspector(ClaudeCodeApiName.Bash),
|
[ClaudeCodeApiName.Bash]: createRunCommandInspector(ClaudeCodeApiName.Bash),
|
||||||
@@ -67,3 +68,10 @@ export const ClaudeCodeInspectors = {
|
|||||||
[ClaudeCodeApiName.Write]: WriteInspector,
|
[ClaudeCodeApiName.Write]: WriteInspector,
|
||||||
...LinearMcpInspectors,
|
...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<string, BuiltinInspector>;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// reason the labels file lives separately from `LinearMcp.tsx`.
|
// reason the labels file lives separately from `LinearMcp.tsx`.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
LINEAR_MCP_PREFIX,
|
isLinearMcpApiName,
|
||||||
LINEAR_TOOL_NAMES,
|
LINEAR_TOOL_NAMES,
|
||||||
parseToolName,
|
parseToolName,
|
||||||
staticLabelFor,
|
staticLabelFor,
|
||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
capitalize,
|
capitalize,
|
||||||
|
getLinearToolSuffix,
|
||||||
|
isLinearMcpApiName,
|
||||||
LINEAR_MCP_PREFIX,
|
LINEAR_MCP_PREFIX,
|
||||||
type ParsedTool,
|
type ParsedTool,
|
||||||
parseToolName,
|
parseToolName,
|
||||||
@@ -24,6 +26,6 @@ export {
|
|||||||
export const LINEAR_MCP_TOOL_NAMES = LINEAR_TOOL_NAMES;
|
export const LINEAR_MCP_TOOL_NAMES = LINEAR_TOOL_NAMES;
|
||||||
|
|
||||||
export const formatLinearMcpShortLabel = (apiName: string): string | null => {
|
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))}`;
|
return `Linear · ${staticLabelFor(parseToolName(apiName))}`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<string, BuiltinRender> = Object.fromEntries(
|
||||||
|
LINEAR_MCP_TOOL_NAMES.map((tool) => [`${LINEAR_MCP_PREFIX}${tool}`, SharedLinearRender]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const LinearMcpRenders: Record<string, BuiltinRender> = new Proxy(FixedLinearMcpRenders, {
|
||||||
|
get: (target, prop) => {
|
||||||
|
if (typeof prop !== 'string') return undefined;
|
||||||
|
return target[prop] || (isLinearMcpApiName(prop) ? SharedLinearRender : undefined);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
import { RunCommandRender } from '@lobechat/shared-tool-ui/renders';
|
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 { ClaudeCodeApiName } from '../../types';
|
||||||
|
import {
|
||||||
|
isLinearMcpApiName,
|
||||||
|
LINEAR_MCP_PREFIX,
|
||||||
|
LINEAR_MCP_TOOL_NAMES,
|
||||||
|
} from '../Inspector/linearMcpLabels';
|
||||||
import Agent from './Agent';
|
import Agent from './Agent';
|
||||||
import AskUserQuestion from './AskUserQuestion';
|
import AskUserQuestion from './AskUserQuestion';
|
||||||
import Edit from './Edit';
|
import Edit from './Edit';
|
||||||
import Glob from './Glob';
|
import Glob from './Glob';
|
||||||
import Grep from './Grep';
|
import Grep from './Grep';
|
||||||
|
import { LinearMcpRenders } from './LinearMcp';
|
||||||
import Read from './Read';
|
import Read from './Read';
|
||||||
import Skill from './Skill';
|
import Skill from './Skill';
|
||||||
import Task from './Task';
|
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
|
* Maps CC tool names (the `name` on Anthropic `tool_use` blocks) to dedicated
|
||||||
* visualizations, keyed so `getBuiltinRender('claude-code', apiName)` resolves.
|
* visualizations, keyed so `getBuiltinRender('claude-code', apiName)` resolves.
|
||||||
*/
|
*/
|
||||||
export const ClaudeCodeRenders = {
|
const FixedClaudeCodeRenders = {
|
||||||
[ClaudeCodeApiName.Agent]: Agent,
|
[ClaudeCodeApiName.Agent]: Agent,
|
||||||
[ClaudeCodeApiName.AskUserQuestion]: AskUserQuestion,
|
[ClaudeCodeApiName.AskUserQuestion]: AskUserQuestion,
|
||||||
// RunCommand already renders `args.command` + combined output the way CC emits —
|
// RunCommand already renders `args.command` + combined output the way CC emits —
|
||||||
@@ -45,8 +51,16 @@ export const ClaudeCodeRenders = {
|
|||||||
[ClaudeCodeApiName.WebFetch]: WebFetch,
|
[ClaudeCodeApiName.WebFetch]: WebFetch,
|
||||||
[ClaudeCodeApiName.WebSearch]: WebSearch,
|
[ClaudeCodeApiName.WebSearch]: WebSearch,
|
||||||
[ClaudeCodeApiName.Write]: Write,
|
[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<string, BuiltinRender>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-APIName default display control for CC tool renders.
|
* 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
|
* can't reach these. The builtin-tools aggregator exposes this map via
|
||||||
* `getBuiltinRenderDisplayControl` as a fallback.
|
* `getBuiltinRenderDisplayControl` as a fallback.
|
||||||
*/
|
*/
|
||||||
export const ClaudeCodeRenderDisplayControls: Record<string, RenderDisplayControl> = {
|
const FixedClaudeCodeRenderDisplayControls: Record<string, RenderDisplayControl> = {
|
||||||
[ClaudeCodeApiName.Edit]: 'expand',
|
[ClaudeCodeApiName.Edit]: 'expand',
|
||||||
[ClaudeCodeApiName.TaskList]: 'expand',
|
[ClaudeCodeApiName.TaskList]: 'expand',
|
||||||
[ClaudeCodeApiName.TaskUpdate]: 'expand',
|
[ClaudeCodeApiName.TaskUpdate]: 'expand',
|
||||||
[ClaudeCodeApiName.TodoWrite]: 'expand',
|
[ClaudeCodeApiName.TodoWrite]: 'expand',
|
||||||
[ClaudeCodeApiName.Write]: 'expand',
|
[ClaudeCodeApiName.Write]: 'expand',
|
||||||
|
...Object.fromEntries(
|
||||||
|
LINEAR_MCP_TOOL_NAMES.map((tool) => [`${LINEAR_MCP_PREFIX}${tool}`, 'expand']),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ClaudeCodeRenderDisplayControls: Record<string, RenderDisplayControl> = new Proxy(
|
||||||
|
FixedClaudeCodeRenderDisplayControls,
|
||||||
|
{
|
||||||
|
get: (target, prop) => {
|
||||||
|
if (typeof prop !== 'string') return undefined;
|
||||||
|
return target[prop] || (isLinearMcpApiName(prop) ? 'expand' : undefined);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
'use client';
|
'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 type { BuiltinRenderProps } from '@lobechat/types';
|
||||||
import { Flexbox, Highlighter, Text } from '@lobehub/ui';
|
import { Flexbox, Highlighter, Text } from '@lobehub/ui';
|
||||||
import { createStaticStyles, cssVar } from 'antd-style';
|
import { createStaticStyles, cssVar } from 'antd-style';
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -10,9 +13,12 @@ import type { CodexMcpToolArgs, CodexMcpToolState } from './mcpToolUtils';
|
|||||||
import {
|
import {
|
||||||
formatMcpInput,
|
formatMcpInput,
|
||||||
formatMcpOutput,
|
formatMcpOutput,
|
||||||
|
getCodexLinearMcpApiName,
|
||||||
getMcpErrorText,
|
getMcpErrorText,
|
||||||
getMcpInput,
|
getMcpInput,
|
||||||
|
getMcpInputRecord,
|
||||||
getMcpResultText,
|
getMcpResultText,
|
||||||
|
getMcpServer,
|
||||||
getMcpToolName,
|
getMcpToolName,
|
||||||
} from './mcpToolUtils';
|
} from './mcpToolUtils';
|
||||||
|
|
||||||
@@ -24,13 +30,42 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
|||||||
`,
|
`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const LINEAR_TOOL_NAME_SET = new Set<string>([...LINEAR_TOOL_NAMES, 'fetch', 'search']);
|
||||||
|
const SharedLinearRender = LinearRender as ComponentType<
|
||||||
|
BuiltinRenderProps<Record<string, unknown>, unknown, string>
|
||||||
|
>;
|
||||||
|
|
||||||
const McpToolRender = memo<BuiltinRenderProps<CodexMcpToolArgs, CodexMcpToolState, string>>(
|
const McpToolRender = memo<BuiltinRenderProps<CodexMcpToolArgs, CodexMcpToolState, string>>(
|
||||||
({ args, content, pluginState }) => {
|
({ args, content, messageId, pluginState, toolCallId }) => {
|
||||||
const { t } = useTranslation('plugin');
|
const { t } = useTranslation('plugin');
|
||||||
|
const server = getMcpServer(args, pluginState);
|
||||||
const toolName = getMcpToolName(args, pluginState);
|
const toolName = getMcpToolName(args, pluginState);
|
||||||
const input = formatMcpInput(getMcpInput(args, pluginState), toolName);
|
const inputRecord = getMcpInputRecord(args, pluginState);
|
||||||
const output = formatMcpOutput(getMcpResultText(content, pluginState, args), toolName);
|
const resultText = getMcpResultText(content, pluginState, args);
|
||||||
const error = getMcpErrorText(pluginState, args);
|
const error = getMcpErrorText(pluginState, args);
|
||||||
|
const linearApiName = getCodexLinearMcpApiName({
|
||||||
|
input: inputRecord,
|
||||||
|
server,
|
||||||
|
toolName,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (LINEAR_TOOL_NAME_SET.has(linearApiName)) {
|
||||||
|
return (
|
||||||
|
<SharedLinearRender
|
||||||
|
apiName={linearApiName}
|
||||||
|
args={inputRecord || {}}
|
||||||
|
content={resultText}
|
||||||
|
identifier={'codex'}
|
||||||
|
messageId={messageId}
|
||||||
|
pluginError={error}
|
||||||
|
pluginState={pluginState}
|
||||||
|
toolCallId={toolCallId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = formatMcpInput(getMcpInput(args, pluginState), toolName);
|
||||||
|
const output = formatMcpOutput(resultText, toolName);
|
||||||
|
|
||||||
if (!input && !output && !error) return null;
|
if (!input && !output && !error) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lobechat/tool-runtime": "workspace:*",
|
"@lobechat/tool-runtime": "workspace:*",
|
||||||
|
"@lobechat/utils": "workspace:*",
|
||||||
"anser": "^2.3.5"
|
"anser": "^2.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
export { LinearInspector } from './Inspector';
|
export { LinearInspector } from './Inspector';
|
||||||
export {
|
export {
|
||||||
capitalize,
|
capitalize,
|
||||||
|
getLinearToolSuffix,
|
||||||
|
isLinearMcpApiName,
|
||||||
LINEAR_MCP_PREFIX,
|
LINEAR_MCP_PREFIX,
|
||||||
LINEAR_TOOL_NAMES,
|
LINEAR_TOOL_NAMES,
|
||||||
type ParsedTool,
|
type ParsedTool,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -78,10 +78,29 @@ export interface ParsedTool {
|
|||||||
verb: 'get' | 'list' | 'save' | 'create' | 'delete' | 'search' | 'extract' | 'prepare' | 'other';
|
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 => {
|
export const parseToolName = (apiName: string): ParsedTool => {
|
||||||
const suffix = apiName.startsWith(LINEAR_MCP_PREFIX)
|
const suffix = getLinearToolSuffix(apiName);
|
||||||
? apiName.slice(LINEAR_MCP_PREFIX.length)
|
|
||||||
: apiName;
|
|
||||||
|
|
||||||
if (suffix === 'extract_images') return { noun: 'images', verb: 'extract' };
|
if (suffix === 'extract_images') return { noun: 'images', verb: 'extract' };
|
||||||
if (suffix === 'prepare_attachment_upload') return { noun: 'attachment upload', verb: 'prepare' };
|
if (suffix === 'prepare_attachment_upload') return { noun: 'attachment upload', verb: 'prepare' };
|
||||||
|
|||||||
@@ -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 = <T,>(items: T[]) => items.length > 0;
|
||||||
|
|
||||||
|
const Section = memo<{ children: ReactNode; title: string }>(({ children, title }) => (
|
||||||
|
<Flexbox gap={6}>
|
||||||
|
<Text className={styles.sectionLabel}>{title}</Text>
|
||||||
|
{children}
|
||||||
|
</Flexbox>
|
||||||
|
));
|
||||||
|
Section.displayName = 'LinearRenderSection';
|
||||||
|
|
||||||
|
const FieldGrid = memo<{ fields: LinearField[] }>(({ fields }) => {
|
||||||
|
if (!hasItems(fields)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.fieldGrid}>
|
||||||
|
{fields.map((field) => (
|
||||||
|
<div className={styles.fieldItem} key={`${field.key}:${field.value}`}>
|
||||||
|
<span className={styles.fieldLabel}>{field.label}</span>
|
||||||
|
<span className={styles.fieldValue} title={field.value}>
|
||||||
|
{field.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FieldGrid.displayName = 'LinearRenderFieldGrid';
|
||||||
|
|
||||||
|
const LinkList = memo<{ links: LinearLink[] }>(({ links }) => {
|
||||||
|
if (!hasItems(links)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flexbox gap={4}>
|
||||||
|
{links.map((link) => (
|
||||||
|
<a
|
||||||
|
className={styles.linkRow}
|
||||||
|
href={link.url}
|
||||||
|
key={`${link.title}:${link.url}`}
|
||||||
|
rel={'noreferrer'}
|
||||||
|
target={'_blank'}
|
||||||
|
>
|
||||||
|
<Icon icon={Link2} size={13} />
|
||||||
|
<Text ellipsis className={styles.linkText} title={link.title}>
|
||||||
|
{link.title}
|
||||||
|
</Text>
|
||||||
|
<Icon icon={ExternalLink} size={12} />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</Flexbox>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
LinkList.displayName = 'LinearRenderLinkList';
|
||||||
|
|
||||||
|
const EntityCard = memo<{ entity: LinearEntity }>(({ entity }) => {
|
||||||
|
const title = entity.title || entity.id || 'Linear item';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Block gap={10} padding={10} variant={'outlined'} width={'100%'}>
|
||||||
|
<div className={styles.entityHeader}>
|
||||||
|
<Flexbox gap={4} style={{ minWidth: 0 }}>
|
||||||
|
{entity.url ? (
|
||||||
|
<a className={styles.titleLink} href={entity.url} rel={'noreferrer'} target={'_blank'}>
|
||||||
|
<Text ellipsis={{ rows: 2 }} weight={600}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Icon icon={ExternalLink} size={12} />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Text ellipsis={{ rows: 2 }} weight={600}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Flexbox horizontal gap={4} wrap={'wrap'}>
|
||||||
|
{entity.id && <Tag size={'small'}>{entity.id}</Tag>}
|
||||||
|
{entity.state && (
|
||||||
|
<Tag size={'small'} variant={'outlined'}>
|
||||||
|
{entity.state}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</Flexbox>
|
||||||
|
</Flexbox>
|
||||||
|
</div>
|
||||||
|
<FieldGrid fields={entity.fields} />
|
||||||
|
{entity.description && (
|
||||||
|
<div className={styles.description}>
|
||||||
|
<Markdown fontSize={13} variant={'chat'}>
|
||||||
|
{entity.description}
|
||||||
|
</Markdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<LinkList links={entity.links} />
|
||||||
|
</Block>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
EntityCard.displayName = 'LinearRenderEntityCard';
|
||||||
|
|
||||||
|
const LinearRender = memo<BuiltinRenderProps<Record<string, unknown>, 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 (
|
||||||
|
<Flexbox className={styles.container} gap={12}>
|
||||||
|
{hasRequest && (
|
||||||
|
<Section title={model.actionLabel || 'Request'}>
|
||||||
|
<Block gap={8} padding={10} variant={'outlined'} width={'100%'}>
|
||||||
|
<FieldGrid fields={model.requestFields} />
|
||||||
|
<LinkList links={model.requestLinks} />
|
||||||
|
</Block>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
{hasItems(model.resultEntities) && (
|
||||||
|
<Section title={'Result'}>
|
||||||
|
<Flexbox gap={8}>
|
||||||
|
{model.resultEntities.map((entity, index) => (
|
||||||
|
<EntityCard
|
||||||
|
entity={entity}
|
||||||
|
key={`${entity.id || entity.title || 'entity'}:${index}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Flexbox>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
{model.resultText && (
|
||||||
|
<Section title={'Result'}>
|
||||||
|
<Highlighter
|
||||||
|
wrap
|
||||||
|
language={'text'}
|
||||||
|
showLanguage={false}
|
||||||
|
style={{ maxHeight: 220, overflow: 'auto', paddingInline: 8 }}
|
||||||
|
variant={'filled'}
|
||||||
|
>
|
||||||
|
{model.resultText}
|
||||||
|
</Highlighter>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
{model.rawResultJson && (
|
||||||
|
<details className={styles.rawDetails}>
|
||||||
|
<summary>Raw result</summary>
|
||||||
|
<Highlighter
|
||||||
|
wrap
|
||||||
|
language={'json'}
|
||||||
|
style={{ maxHeight: 260, overflow: 'auto', paddingInline: 8 }}
|
||||||
|
variant={'filled'}
|
||||||
|
>
|
||||||
|
{model.rawResultJson}
|
||||||
|
</Highlighter>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
{model.errorText && (
|
||||||
|
<Section title={'Error'}>
|
||||||
|
<Highlighter
|
||||||
|
wrap
|
||||||
|
language={'text'}
|
||||||
|
showLanguage={false}
|
||||||
|
style={{ maxHeight: 220, overflow: 'auto', paddingInline: 8 }}
|
||||||
|
variant={'filled'}
|
||||||
|
>
|
||||||
|
{model.errorText}
|
||||||
|
</Highlighter>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</Flexbox>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
LinearRender.displayName = 'LinearRender';
|
||||||
|
|
||||||
|
export default LinearRender;
|
||||||
@@ -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.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<number, string> = {
|
||||||
|
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<PropertyKey, unknown>, 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<PropertyKey, unknown>,
|
||||||
|
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<PropertyKey, unknown>) =>
|
||||||
|
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<PropertyKey, unknown>): 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<PropertyKey, unknown>[] => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1 +1,2 @@
|
|||||||
|
export { default as LinearRender } from './Linear';
|
||||||
export { default as RunCommandRender } from './RunCommand';
|
export { default as RunCommandRender } from './RunCommand';
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ describe('tool display names', () => {
|
|||||||
expect(summary).not.toContain('Mcp_tool_call');
|
expect(summary).not.toContain('Mcp_tool_call');
|
||||||
expect(summary).not.toContain('Web_search');
|
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', () => {
|
describe('shapeProseForWorkflowHeadline', () => {
|
||||||
|
|||||||
@@ -4,6 +4,22 @@ import { ClaudeCodeIdentifier } from '@lobechat/builtin-tool-claude-code/client'
|
|||||||
|
|
||||||
import { defineFixtures, single, variants } from './_helpers';
|
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({
|
export default defineFixtures({
|
||||||
identifier: ClaudeCodeIdentifier,
|
identifier: ClaudeCodeIdentifier,
|
||||||
meta: {
|
meta: {
|
||||||
@@ -87,6 +103,10 @@ export default defineFixtures({
|
|||||||
description: 'Write a new file.',
|
description: 'Write a new file.',
|
||||||
name: 'Write',
|
name: 'Write',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
description: 'Update a Linear issue through MCP.',
|
||||||
|
name: linearIssueApiName,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
fixtures: {
|
fixtures: {
|
||||||
Agent: single({
|
Agent: single({
|
||||||
@@ -396,5 +416,13 @@ export default defineFixtures({
|
|||||||
file_path: 'src/routes/(main)/devtools/featureFlag.ts',
|
file_path: 'src/routes/(main)/devtools/featureFlag.ts',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
[linearIssueApiName]: single({
|
||||||
|
args: {
|
||||||
|
id: 'LOBE-10205',
|
||||||
|
links: linearIssueResult.links,
|
||||||
|
state: 'In Review',
|
||||||
|
},
|
||||||
|
content: JSON.stringify(linearIssueResult),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'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
|
const addedFileDiff = `diff --git a/src/routes/(main)/devtools/index.tsx b/src/routes/(main)/devtools/index.tsx
|
||||||
--- /dev/null
|
--- /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({
|
export default defineFixtures({
|
||||||
identifier: 'codex',
|
identifier: 'codex',
|
||||||
meta: {
|
meta: {
|
||||||
@@ -113,28 +128,58 @@ export default defineFixtures({
|
|||||||
linesDeleted: 0,
|
linesDeleted: 0,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
mcp_tool_call: single({
|
mcp_tool_call: variants([
|
||||||
args: {
|
{
|
||||||
arguments: {
|
args: {
|
||||||
code: "const result = await import('./package.json', { with: { type: 'json' } });\nresult.default.name;",
|
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: {
|
args: {
|
||||||
arguments: {
|
arguments: {
|
||||||
code: "const result = await import('./package.json', { with: { type: 'json' } });\nresult.default.name;",
|
id: 'LOBE-10205',
|
||||||
|
links: linearIssueResult.links,
|
||||||
|
state: 'In Review',
|
||||||
|
},
|
||||||
|
server: 'mcp__codex_apps__linear',
|
||||||
|
tool: 'linear_save_issue',
|
||||||
},
|
},
|
||||||
result: {
|
content: JSON.stringify(linearIssueResult),
|
||||||
content: [{ text: '@lobehub/desktop', type: 'text' }],
|
label: 'Linear update issue',
|
||||||
isError: false,
|
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({
|
todo_list: single({
|
||||||
args: {
|
args: {
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { WEB_ONBOARDING } from '@lobechat/builtin-agents';
|
import { WEB_ONBOARDING } from '@lobechat/builtin-agents';
|
||||||
|
import { ClaudeCodeIdentifier as ClaudeCodeToolIdentifier } from '@lobechat/builtin-tool-claude-code/client';
|
||||||
import {
|
import {
|
||||||
GroupAgentBuilderApiName,
|
GroupAgentBuilderApiName,
|
||||||
GroupAgentBuilderIdentifier,
|
GroupAgentBuilderIdentifier,
|
||||||
@@ -12,7 +13,10 @@ import {
|
|||||||
WebOnboardingIdentifier,
|
WebOnboardingIdentifier,
|
||||||
WebOnboardingManifest,
|
WebOnboardingManifest,
|
||||||
} from '@lobechat/builtin-tool-web-onboarding';
|
} from '@lobechat/builtin-tool-web-onboarding';
|
||||||
|
import { getBuiltinRenderDisplayControl } from '@lobechat/builtin-tools/displayControls';
|
||||||
import { builtinToolIdentifiers } from '@lobechat/builtin-tools/identifiers';
|
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';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
describe('builtin tool registry', () => {
|
describe('builtin tool registry', () => {
|
||||||
@@ -36,6 +40,14 @@ describe('builtin tool registry', () => {
|
|||||||
expect(GroupAgentBuilderInspectors[GroupAgentBuilderApiName.createGroup]).toBeDefined();
|
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', () => {
|
it('includes user interaction and web onboarding in web onboarding runtime plugins', () => {
|
||||||
const runtime =
|
const runtime =
|
||||||
typeof WEB_ONBOARDING.runtime === 'function'
|
typeof WEB_ONBOARDING.runtime === 'function'
|
||||||
|
|||||||
Reference in New Issue
Block a user