mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +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 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<string, BuiltinInspector> = Object.fromEntries(
|
||||
const FixedLinearMcpInspectors: Record<string, BuiltinInspector> = Object.fromEntries(
|
||||
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,
|
||||
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<string, BuiltinInspector>;
|
||||
|
||||
@@ -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))}`;
|
||||
};
|
||||
|
||||
@@ -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 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<string, BuiltinRender>;
|
||||
|
||||
/**
|
||||
* 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<string, RenderDisplayControl> = {
|
||||
const FixedClaudeCodeRenderDisplayControls: Record<string, RenderDisplayControl> = {
|
||||
[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<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';
|
||||
|
||||
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<string>([...LINEAR_TOOL_NAMES, 'fetch', 'search']);
|
||||
const SharedLinearRender = LinearRender as ComponentType<
|
||||
BuiltinRenderProps<Record<string, unknown>, unknown, string>
|
||||
>;
|
||||
|
||||
const McpToolRender = memo<BuiltinRenderProps<CodexMcpToolArgs, CodexMcpToolState, string>>(
|
||||
({ 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 (
|
||||
<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;
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@lobechat/tool-runtime": "workspace:*",
|
||||
"@lobechat/utils": "workspace:*",
|
||||
"anser": "^2.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export { LinearInspector } from './Inspector';
|
||||
export {
|
||||
capitalize,
|
||||
getLinearToolSuffix,
|
||||
isLinearMcpApiName,
|
||||
LINEAR_MCP_PREFIX,
|
||||
LINEAR_TOOL_NAMES,
|
||||
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';
|
||||
}
|
||||
|
||||
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' };
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user