Skip to content
53 changes: 53 additions & 0 deletions apps/desktop/stories/composer-skill-draft.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,56 @@ export const ImeCommitDoesNotSend: Story = {
await expect(sent).toHaveBeenCalledWith('中文草稿');
},
};

/**
* The chip's box is one line box tall and the pill is centred inside it, so the
* chip sits on the text's line. `vertical-align: middle` centred the box on the
* x-height midline instead, which left a 20px chip ~1px low beside CJK text.
*
* Both halves are asserted on the production editor: the box height (the
* contract the CSS states) and the resulting centres (what a reader sees). The
* same declarations are in the transcript's token wrapper, so a token does not
* move when the message is sent.
*/
function lineGeometry(composer: HTMLElement, chip: HTMLElement) {
const lineHeight = Number.parseFloat(getComputedStyle(composer).lineHeight);
const chipRect = chip.getBoundingClientRect();
// The text run's own box, centred in its line box by the line height.
let textRect: DOMRect | undefined;
for (const node of composer.childNodes) {
if (node.nodeType !== Node.TEXT_NODE || !(node.textContent ?? '').trim()) continue;
const range = document.createRange();
range.selectNodeContents(node);
textRect = range.getClientRects()[0];
if (textRect) break;
}
if (!textRect) throw new Error('the composer has no text to measure against');
const lineCenter = textRect.top - (lineHeight - textRect.height) / 2 + lineHeight / 2;
return {
lineHeight,
chipBoxHeight: chipRect.height,
chipCenter: (chipRect.top + chipRect.bottom) / 2,
lineCenter,
};
}

// Real path: 在 Session 的 composer 里用 `/` 选一个 Skill,然后在 chip 后面接着输入中文。
export const StagedSkillSitsOnTheTextLine: Story = {
play: async ({ canvasElement }) => {
const composer = editor(canvasElement);
await pickSkill(composer, 'project', /Project Only/);
await userEvent.keyboard('测试测试测试');
// The chip's label arrives with its portal, one commit after the token.
const staged = await waitFor(() => {
const found = chip('project-only');
if (!found || !(found.textContent ?? '').includes('Project Only')) {
throw new Error('the staged chip has not rendered yet');
}
return found;
});

const geometry = lineGeometry(composer, staged);
await expect(Math.abs(geometry.chipBoxHeight - geometry.lineHeight)).toBeLessThan(1);
await expect(Math.abs(geometry.chipCenter - geometry.lineCenter)).toBeLessThan(1);
},
};
2 changes: 1 addition & 1 deletion docs/astryx-surface-file-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `packages/ui/src/chat-empty-hero.tsx` | shell-chrome-or-panel | Item | aligned — uses Astryx (Item) | aligned |
| `packages/ui/src/chat-model-switcher.tsx` | shell-chrome-or-panel | Button, Selector, SelectorOption | aligned — uses Astryx (Button, Selector, SelectorOption) | aligned |
| `packages/ui/src/chat-surface-layout.tsx` | shell-chrome-or-panel | ChatLayout | aligned — uses Astryx (ChatLayout) | aligned |
| `packages/ui/src/chat-turn.tsx` | shell-chrome-or-panel | Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, ChatTokenizedText, HStack, Icon, IconButton, Spinner, Thumbnail, Timestamp, Token, Tooltip | aligned — uses Astryx (Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, ChatTokenizedText) | aligned |
| `packages/ui/src/chat-turn.tsx` | shell-chrome-or-panel | Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, HStack, Icon, IconButton, Spinner, Thumbnail, Timestamp, Token, Tooltip | aligned — uses Astryx (Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, HStack) | aligned |
| `packages/ui/src/chat-view.tsx` | shell-chrome-or-panel | Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner | aligned — uses Astryx (Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner) | aligned |
| `packages/ui/src/choice-panel.tsx` | shell-chrome-or-panel | Badge, Item | aligned — uses Astryx (Badge, Item) | aligned |
| `packages/ui/src/client-capability-prompt.tsx` | ui-composition | Button | aligned — uses Astryx (Button) | aligned |
Expand Down
18 changes: 18 additions & 0 deletions packages/ui/src/__tests__/chat-input-behavior.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
composerWireText,
createTriggerSearchSource,
isChatInputComposing,
mentionMatchRank,
selectedSkillIds,
skillMentionQuery,
slashCommandQuery,
Expand Down Expand Up @@ -140,6 +141,23 @@ describe('shared chat input behavior', () => {
assert.equal(slashCommandQuery('comp', '', 'comp'), null);
});

it('ranks a name match above a description match', () => {
// `debug` vs `avoid-ai-writing`: typing `de` must surface the Skill whose
// own id answers the query, not the one whose prose happens to contain it.
const debug = mentionMatchRank('de', 'debug debug');
const proseOnly = mentionMatchRank('de', 'avoid-ai-writing avoid-ai-writing');
assert.equal(debug, 0);
assert.equal(proseOnly, 3);
assert.ok(debug < proseOnly);
// Prefix, then anywhere in the id/name, then prose only.
assert.equal(mentionMatchRank('pro', 'project-only Project Only'), 0);
assert.equal(mentionMatchRank('only', 'project-only Project Only'), 1);
assert.equal(mentionMatchRank(' ', 'project-only Project Only'), 0);
assert.equal(mentionMatchRank('comp', 'compact'), 0);
assert.equal(mentionMatchRank('pact', 'compact'), 1);
assert.equal(mentionMatchRank('compact', 'side'), 3);
});

it('reads `/skill:<query>` and a bare `/<query>` as the same Skill search', () => {
assert.equal(skillMentionQuery('skill:comp'), 'comp');
assert.equal(skillMentionQuery('SKILL:Comp'), 'Comp');
Expand Down
197 changes: 197 additions & 0 deletions packages/ui/src/__tests__/chat-turn-inline-references.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

/**
* A user row draws its Skill chips from the text it carries.
*
* `inlineReferences` is a frozen rendering hint the Host composes from the
* invocation receipts, so the optimistic row, the desktop's local copy and any
* invocation with no successful receipt carry the token as plain text and an
* empty array. Reading that array as "this message has no tokens" left those
* rows showing `/skill:writer` while the canonical copy of the same message
* showed a chip.
*/

import assert from 'node:assert/strict';
import { afterEach, test } from 'node:test';
import type { InlineReference } from '@maka/core/events';
import { TurnView } from '../chat-turn.js';
import { LocaleProvider } from '../locale-context.js';
import type { ChatItem, TurnViewModel } from '../materialize.js';
import { installTranscriptDom, type TranscriptDom } from './transcript-test-dom.js';

let dom: TranscriptDom | undefined;

afterEach(async () => {
await dom?.cleanup();
dom = undefined;
});

function userTurn(user: ChatItem): TurnViewModel {
return {
turnId: 'turn-1',
status: 'completed',
user,
tools: [],
notes: [],
startedAt: 1,
timeline: [],
};
}

async function renderUserRow(user: ChatItem): Promise<HTMLElement> {
dom = installTranscriptDom();
await dom.render(
<LocaleProvider locale="en">
<TurnView turn={userTurn(user)} />
</LocaleProvider>,
);
const bubble = dom.container.querySelector('.maka-chat-message-bubble-user');
assert.ok(bubble, 'the user row rendered no bubble');
return bubble as unknown as HTMLElement;
}

function chipLabels(bubble: HTMLElement): string[] {
return [...bubble.querySelectorAll('.astryx-badge')].map(
(badge) => (badge.textContent ?? '').trim(),
);
}

const WRITER_FILE: InlineReference = {
kind: 'workspace_file',
value: '@notes/writer.md',
label: 'writer.md',
start: 5,
};

test('draws a Skill chip from the token when the row carries no references', async () => {
const bubble = await renderUserRow({
id: 'ask',
role: 'user',
text: 'run /skill:writer on this',
ts: 1,
inlineReferences: [],
});

assert.deepEqual(chipLabels(bubble), ['writer']);
assert.ok(
!(bubble.textContent ?? '').includes('/skill:writer'),
'the raw token must not survive beside its chip',
);
assert.equal(bubble.textContent, 'run writer on this');
});

test('draws the same chip when the row carries no reference field at all', async () => {
const bubble = await renderUserRow({
id: 'ask',
role: 'user',
text: 'run /skill:writer on this',
ts: 1,
});

assert.deepEqual(chipLabels(bubble), ['writer']);
});

test('prefers the frozen label when the Host composed one', async () => {
const bubble = await renderUserRow({
id: 'ask',
role: 'user',
text: 'run /skill:writer on this',
ts: 1,
inlineReferences: [
{ kind: 'skill', value: '/skill:writer', label: 'Writer', start: 4 },
],
});

assert.deepEqual(chipLabels(bubble), ['Writer']);
assert.ok(!(bubble.textContent ?? '').includes('/skill:writer'));
});

test('keeps a file chip and a Skill chip side by side', async () => {
const bubble = await renderUserRow({
id: 'ask',
role: 'user',
text: 'read @notes/writer.md then /skill:writer',
ts: 1,
inlineReferences: [WRITER_FILE],
});

assert.deepEqual(chipLabels(bubble), ['writer.md', 'writer']);
assert.ok(!(bubble.textContent ?? '').includes('/skill:writer'));
assert.equal(bubble.textContent, 'read writer.md then writer');
});

test('leaves a reference the text no longer holds as text, and still chips the token', async () => {
const bubble = await renderUserRow({
id: 'ask',
role: 'user',
text: 'read something else then /skill:writer',
ts: 1,
inlineReferences: [WRITER_FILE],
});

assert.deepEqual(chipLabels(bubble), ['writer']);
assert.equal(bubble.textContent, 'read something else then writer');
});

test('does not chip a token the grammar rejects at its position', async () => {
// `a/skill:writer` is a URL-shaped mention the grammar's `(?<=\s)` excludes;
// drawing chips by token *value* chips it anyway — and when the invocation's
// receipt failed the wrong chip stayed in the final transcript forever.
const bubble = await renderUserRow({
id: 'ask',
role: 'user',
text: '参考 a/skill:writer 再调 /skill:writer',
ts: 1,
inlineReferences: [],
});

assert.deepEqual(chipLabels(bubble), ['writer']);
assert.equal(bubble.textContent, '参考 a/skill:writer 再调 writer');
});

test('does not chip a token that starts right after a reference span', async () => {
// The text's own boundary check sees `d/`, not the whitespace the grammar
// requires — the gap slice's start is not a real `^`.
const bubble = await renderUserRow({
id: 'ask',
role: 'user',
text: 'read @notes/writer.md/skill:review',
ts: 1,
inlineReferences: [WRITER_FILE],
});

assert.deepEqual(chipLabels(bubble), ['writer.md']);
assert.equal(bubble.textContent, 'read writer.md/skill:review');
});

test('chips prefix-related ids at their own positions', async () => {
// Value-set tokenization let the shorter `review` match inside `reviewer`
// first, leaving a `review` chip plus a stray `er`.
const bubble = await renderUserRow({
id: 'ask',
role: 'user',
text: 'run /skill:review and /skill:reviewer',
ts: 1,
inlineReferences: [],
});

assert.deepEqual(chipLabels(bubble), ['review', 'reviewer']);
assert.equal(bubble.textContent, 'run review and reviewer');
});
22 changes: 22 additions & 0 deletions packages/ui/src/chat-input-behavior.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,28 @@ export function mentionQueryMatches(query: string, text: string): boolean {
.every((token) => haystack.includes(token));
}

/**
* How well one `/`-menu candidate answers the typed query, lower first: 0 for
* a prefix of `primary`, 1 for a substring of it, 3 when the match lives only
* in the description the filter also searched.
*
* The menu orders by this ahead of its catalog order. `mentionQueryMatches`
* alone treats a description as good as a name, so one or two typed letters of
* a Skill's own name sorted below every Skill that merely mentions the word in
* its prose — the list looked like it had not matched at all until the query
* grew long enough to exclude those descriptions.
*
* `primary` is what a user is naming: a Skill's id and name, a command's id,
* name and keywords.
*/
export function mentionMatchRank(query: string, primary: string): 0 | 1 | 3 {
const normalized = query.trim().toLowerCase();
if (!normalized) return 0;
const haystack = primary.toLowerCase();
if (haystack.startsWith(normalized)) return 0;
return haystack.includes(normalized) ? 1 : 3;
}

/** Normalize `/skill:<query>` and bare `/<query>` into the same Skill search query. */
export function skillMentionQuery(query: string): string {
return query.toLowerCase().startsWith('skill:') ? query.slice('skill:'.length) : query;
Expand Down
17 changes: 1 addition & 16 deletions packages/ui/src/chat-turn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import {
ChatMessageBubble,
ChatMessageMetadata,
ChatSystemMessage,
ChatTokenizedText,
HStack,
IconButton as UiIconButton,
Spinner,
Expand All @@ -46,7 +45,6 @@ import {
import { ChatReasoning } from './astryx-chat-reasoning.js';
import { Tooltip } from '@astryxdesign/core/Tooltip';
import { Icon } from '@astryxdesign/core/Icon';
import { SKILL_INVOCATION_TOKEN_SOURCE } from '@maka/core/skill-invocation-token';
import {
type AttachmentRef,
type InlineReference,
Expand Down Expand Up @@ -96,13 +94,6 @@ export function LocalizedChatMessage({
);
}

function legacySentSkillTokens(text: string) {
const values = new Set(
[...text.matchAll(new RegExp(SKILL_INVOCATION_TOKEN_SOURCE, 'g'))].map((match) => match[0]),
);
return [...values].map((value) => ({ value, label: value, variant: 'neutral' as const }));
}

function AttachmentImage(props: { attachment: AttachmentRef }) {
const preview = resolvePreviewKind({
name: props.attachment.name,
Expand Down Expand Up @@ -273,13 +264,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
className="maka-chat-message-bubble maka-chat-message-bubble-user"
metadata={userMetadata}
>
{props.inlineReferences ? (
<InlineReferenceText text={props.text} references={props.inlineReferences} />
) : (
<ChatTokenizedText tokens={legacySentSkillTokens(props.text)}>
{props.text}
</ChatTokenizedText>
)}
<InlineReferenceText text={props.text} references={props.inlineReferences ?? []} />
</ChatMessageBubble>
) : (
userMetadata
Expand Down
Loading