Compare commits

...

2 Commits

Author SHA1 Message Date
Arvin Xu e3765c8967 💄 fix(linear): don't use UUID id as card title; show full date+time
Comments/attachments have no human title, only a UUID id — the render
promoted that UUID to the big card title (duplicating the id tag below).
Now the title block only renders when a real title exists; the id stays a
secondary tag (linked when a url is present) and the comment body carries
the card. Also format timestamps as `YYYY-MM-DD HH:mm:ss` instead of `HH:mm`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 11:59:35 +08:00
Arvin Xu e6970911be 💄 fix(linear): render get_issue result as a card instead of raw JSON
Single-entity Linear endpoints (get_issue, save_issue, …) return the
entity directly, often embedding empty sub-collections like
`documents: []`. The render's `extractResultRecords` matched any
`RESULT_ARRAY_KEYS` key, so an empty `documents: []` was treated as the
result set, yielding zero entities and falling back to the raw-JSON
"Raw result" view. Now only `list_*` endpoints scan nested collections;
single-entity results keep the object as the entity so it renders as a
card. Also dedupe the `status` field that duplicates the state tag and
trim ISO timestamps to a compact form.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 11:39:18 +08:00
3 changed files with 166 additions and 38 deletions
@@ -177,26 +177,38 @@ const LinkList = memo<{ links: LinearLink[] }>(({ links }) => {
LinkList.displayName = 'LinearRenderLinkList';
const EntityCard = memo<{ entity: LinearEntity }>(({ entity }) => {
const title = entity.title || entity.id || 'Linear item';
// Comments / attachments have no human-readable title — only a UUID `id`.
// Never promote that UUID into the card title; keep the id as a secondary tag
// (linked when a url exists) and let the description carry the card.
const { title, id, url } = entity;
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'}>
{title &&
(url ? (
<a className={styles.titleLink} href={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>
<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>}
{id &&
(url && !title ? (
<a className={styles.titleLink} href={url} rel={'noreferrer'} target={'_blank'}>
<Tag size={'small'}>{id}</Tag>
<Icon icon={ExternalLink} size={12} />
</a>
) : (
<Tag size={'small'}>{id}</Tag>
))}
{entity.state && (
<Tag size={'small'} variant={'outlined'}>
{entity.state}
@@ -7,43 +7,43 @@ describe('buildLinearRenderModel', () => {
const model = buildLinearRenderModel({
apiName: 'save_issue',
args: {
id: 'LOBE-10205',
id: 'TEST-123',
links: [
{
title: 'PR #15766: refactor(chat): unify agent run lifecycle',
url: 'https://github.com/lobehub/lobehub/pull/15766',
title: 'PR #1: mock pull request',
url: 'https://github.com/acme/repo/pull/1',
},
],
state: 'In Review',
},
content: JSON.stringify({
description: '## 背景\n\n统一三种客户端 Agent Runtime 的 run 生命周期 hooks。',
id: 'LOBE-10205',
description: '## Background\n\nMock issue description body.',
id: 'TEST-123',
links: [
{
title: 'PR #15766: refactor(chat): unify agent run lifecycle',
url: 'https://github.com/lobehub/lobehub/pull/15766',
title: 'PR #1: mock pull request',
url: 'https://github.com/acme/repo/pull/1',
},
],
state: { name: 'In Review' },
title: '统一三种客户端 Agent Runtime 的 run 生命周期 hooks',
url: 'https://linear.app/lobehub/issue/LOBE-10205',
title: 'Mock issue title',
url: 'https://linear.app/acme/issue/TEST-123',
}),
});
expect(model.actionLabel).toBe('Save issue');
expect(model.requestFields).toEqual([
{ key: 'id', label: 'ID', value: 'LOBE-10205' },
{ key: 'id', label: 'ID', value: 'TEST-123' },
{ 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',
description: '## Background\n\nMock issue description body.',
id: 'TEST-123',
state: 'In Review',
title: '统一三种客户端 Agent Runtime 的 run 生命周期 hooks',
url: 'https://linear.app/lobehub/issue/LOBE-10205',
title: 'Mock issue title',
url: 'https://linear.app/acme/issue/TEST-123',
});
expect(model.rawResultJson).toBeUndefined();
});
@@ -51,18 +51,103 @@ describe('buildLinearRenderModel', () => {
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"}',
args: { id: 'issue:TEST-123' },
content: '{"title":"Mock issue title"}',
});
expect(model.requestFields).toContainEqual({ key: 'id', label: 'ID', value: 'LOBE-10205' });
expect(model.resultEntities[0]).toMatchObject({ title: 'Resume error on topic switch' });
expect(model.requestFields).toContainEqual({ key: 'id', label: 'ID', value: 'TEST-123' });
expect(model.resultEntities[0]).toMatchObject({ title: 'Mock issue title' });
});
it('renders a get_issue entity even when it embeds empty sub-collections', () => {
const model = buildLinearRenderModel({
apiName: 'mcp__claude_ai_Linear__get_issue',
args: { id: 'TEST-456' },
content: JSON.stringify({
assignee: 'Mock User',
attachments: [],
createdAt: '2024-01-02T03:04:05.000Z',
description: '## Mock description',
documents: [],
id: 'TEST-456',
labels: [],
parentId: 'TEST-400',
priority: { name: 'Medium', value: 3 },
project: 'Mock Project',
status: 'Backlog',
team: 'Mock Team',
title: 'Mock issue title',
updatedAt: '2024-01-02T03:04:05.000Z',
url: 'https://linear.app/acme/issue/TEST-456',
}),
});
expect(model.rawResultJson).toBeUndefined();
expect(model.resultEntities).toHaveLength(1);
const [entity] = model.resultEntities;
expect(entity).toMatchObject({
description: '## Mock description',
id: 'TEST-456',
state: 'Backlog',
title: 'Mock issue title',
url: 'https://linear.app/acme/issue/TEST-456',
});
// `status` is surfaced via the state tag, so it should not duplicate as a field.
expect(entity.fields.find((field) => field.key === 'status')).toBeUndefined();
// ISO timestamps render as a concrete date + time.
expect(entity.fields).toContainEqual({
key: 'createdAt',
label: 'Created At',
value: '2024-01-02 03:04:05',
});
expect(entity.fields).toContainEqual({ key: 'priority', label: 'Priority', value: 'Medium' });
});
it('keeps a comment entity titleless (UUID id stays a tag, not the title) with dated fields', () => {
const model = buildLinearRenderModel({
apiName: 'mcp__claude_ai_Linear__create_comment',
args: { issueId: 'TEST-456' },
content: JSON.stringify({
body: '## Mock comment body',
createdAt: '2024-01-02T03:04:05.080Z',
id: 'ff0dabda-eb1f-4dfe-b525-09114c0d6bd0',
updatedAt: '2024-01-02T03:04:05.038Z',
}),
});
expect(model.resultEntities).toHaveLength(1);
const [entity] = model.resultEntities;
// No human title — the render must not promote the UUID id to the title.
expect(entity.title).toBeUndefined();
expect(entity.id).toBe('ff0dabda-eb1f-4dfe-b525-09114c0d6bd0');
expect(entity.description).toBe('## Mock comment body');
expect(entity.fields).toContainEqual({
key: 'createdAt',
label: 'Created At',
value: '2024-01-02 03:04:05',
});
});
it('still unwraps list_* payloads from their nested collection', () => {
const model = buildLinearRenderModel({
apiName: 'list_issues',
args: {},
content: JSON.stringify({
issues: [
{ id: 'TEST-1', title: 'First mock issue' },
{ id: 'TEST-2', title: 'Second mock issue' },
],
}),
});
expect(model.resultEntities).toHaveLength(2);
expect(model.resultEntities.map((entity) => entity.id)).toEqual(['TEST-1', 'TEST-2']);
});
it('keeps non-JSON result text as a readable fallback', () => {
const model = buildLinearRenderModel({
apiName: 'search',
args: { query: 'agent runtime' },
args: { query: 'mock query' },
content: 'No matching Linear issues found.',
});
@@ -1,7 +1,7 @@
import { isRecord } from '@lobechat/utils/object';
import { safeParseJSON } from '@lobechat/utils/safeParseJSON';
import { parseToolName, staticLabelFor } from '../../Inspector/Linear/labels';
import { type ParsedTool, parseToolName, staticLabelFor } from '../../Inspector/Linear/labels';
const PRIORITY_LABEL: Record<number, string> = {
0: 'None',
@@ -129,6 +129,25 @@ const readDisplayString = (value: unknown, key?: string): string | undefined =>
}
};
const DATE_FIELD_KEYS = new Set([
'createdAt',
'updatedAt',
'completedAt',
'startedAt',
'canceledAt',
'archivedAt',
'dueDate',
]);
// Linear timestamps arrive as ISO strings (`2026-06-16T02:14:32.612Z`); show the
// concrete date + time as `YYYY-MM-DD HH:mm:ss` (dropping the millisecond / `Z`
// noise) instead of the raw ISO string. Date-only values (e.g. `dueDate`) are
// left untouched.
const formatIsoDate = (value: string): string => {
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}(?::\d{2})?)/u.exec(value);
return match ? `${match[1]} ${match[2]}` : value;
};
const pickField = (record: Record<PropertyKey, unknown>, key: string): LinearField | undefined => {
const value = readDisplayString(record[key], key);
if (!value) return;
@@ -136,7 +155,7 @@ const pickField = (record: Record<PropertyKey, unknown>, key: string): LinearFie
return {
key,
label: key === 'id' ? 'ID' : key === 'url' ? 'URL' : toLabel(key),
value,
value: DATE_FIELD_KEYS.has(key) ? formatIsoDate(value) : value,
};
};
@@ -232,7 +251,7 @@ const buildEntity = (record: Record<PropertyKey, unknown>): LinearEntity | undef
readDisplayString(record.body) ||
readDisplayString(record.content);
const fields = collectFields(record, ENTITY_FIELD_KEYS).filter(
(field) => field.key !== 'state' || field.value !== state,
(field) => !((field.key === 'state' || field.key === 'status') && field.value === state),
);
const links = getLinearLinks(record.links);
@@ -250,13 +269,25 @@ const buildEntity = (record: Record<PropertyKey, unknown>): LinearEntity | undef
};
};
const extractResultRecords = (value: unknown): Record<PropertyKey, unknown>[] => {
const extractResultRecords = (
value: unknown,
verb?: ParsedTool['verb'],
): 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);
// Only `list_*` endpoints carry their payload in a nested collection
// (`{ issues: [...] }`, `{ nodes: [...] }`). Single-entity endpoints like
// `get_issue` return the entity directly — and that entity often embeds its
// own sub-collections (`documents: []`, `attachments: []`) whose keys overlap
// RESULT_ARRAY_KEYS. Matching those would wrongly drop the whole entity (e.g.
// an empty `documents: []` yielding zero records → raw JSON fallback), so we
// scan nested arrays for list endpoints only.
if (verb === 'list') {
for (const key of RESULT_ARRAY_KEYS) {
const nested = value[key];
if (Array.isArray(nested)) return nested.filter(isRecord);
}
}
return [value];
@@ -287,7 +318,7 @@ export const buildLinearRenderModel = ({
}): LinearRenderModel => {
const parsedTool = parseToolName(apiName || '');
const result = parseResultContent(content);
const resultEntities = extractResultRecords(result)
const resultEntities = extractResultRecords(result, parsedTool.verb)
.map(buildEntity)
.filter((entity): entity is LinearEntity => Boolean(entity));
const resultText = typeof result === 'string' ? result : undefined;