💄 style: improve shared Linear tool rendering (#15769)

This commit is contained in:
Arvin Xu
2026-06-13 18:37:51 +08:00
committed by GitHub
parent 3f3f12dbd2
commit a48c2badd9
18 changed files with 955 additions and 32 deletions
@@ -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;
+1
View File
@@ -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'