Skip to content

Commit 992d693

Browse files
committed
Address PR feedback and fix minor issues with selection
1 parent aa572c2 commit 992d693

20 files changed

Lines changed: 1496 additions & 1088 deletions

pages/prompt-input/token-renderer.page.tsx

Lines changed: 3 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,33 +5,11 @@ import ReactDOM from 'react-dom';
55

66
import { PromptInputProps } from '~components/prompt-input';
77
import { extractTokensFromDOM } from '~components/prompt-input/core/token-operations';
8-
import { PortalContainer, RenderTokenProps, renderTokensToDOM } from '~components/prompt-input/core/token-renderer';
8+
import { PortalContainer, renderTokensToDOM } from '~components/prompt-input/core/token-renderer';
9+
import Token from '~components/token/internal';
910

1011
import { SimplePage } from '../app/templates';
1112

12-
// Custom token renderer — intentionally NOT using the Token component.
13-
// This proves the renderer is decoupled from any specific UI component.
14-
function CustomToken({ label }: RenderTokenProps) {
15-
return (
16-
<span
17-
style={{
18-
display: 'inline-block',
19-
padding: '2px 8px',
20-
margin: '0 2px',
21-
borderRadius: 12,
22-
background: '#fce4ec',
23-
border: '2px dashed #e91e63',
24-
fontSize: 13,
25-
lineHeight: '18px',
26-
fontWeight: 600,
27-
color: '#880e4f',
28-
}}
29-
>
30-
{label}
31-
</span>
32-
);
33-
}
34-
3513
let nextId = 1;
3614

3715
// Menu definitions for trigger/reference token extraction
@@ -158,16 +136,9 @@ export default function TokenRendererPage() {
158136
}}
159137
/>
160138

161-
{/* Render reference tokens into their DOM containers via portals */}
162139
{Array.from(portalContainersRef.current.values()).map(container =>
163140
ReactDOM.createPortal(
164-
<CustomToken
165-
key={container.id}
166-
id={container.id}
167-
label={container.label}
168-
disabled={false}
169-
readOnly={false}
170-
/>,
141+
<Token key={container.id} variant="inline" label={container.label} disabled={false} readOnly={false} />,
171142
container.element
172143
)
173144
)}

src/prompt-input/__integ__/prompt-input-token-mode.test.ts

Lines changed: 109 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -282,18 +282,18 @@ const setupTest = (testFn: (page: PromptInputTokenModePage) => Promise<void>) =>
282282
await page.keys(['Home']);
283283
await page.pause(100);
284284

285-
// Select forward: "hi " then the reference then " bye"
285+
// Select "hi " (3 characters)
286286
for (let i = 0; i < 3; i++) {
287287
await page.keys(['Shift', 'ArrowRight', 'Shift']);
288288
}
289289
expect(await page.getSelectedText()).toBe('hi ');
290290

291-
// One more should jump over the reference
291+
// One more jumps over the atomic reference
292292
await page.keys(['Shift', 'ArrowRight', 'Shift']);
293293
await page.pause(100);
294294
const selected = await page.getSelectedText();
295295
expect(selected).toContain('hi ');
296-
expect(selected).toContain('John Smith');
296+
expect(selected).toContain('Jane Smith');
297297
})
298298
);
299299

@@ -309,24 +309,24 @@ const setupTest = (testFn: (page: PromptInputTokenModePage) => Promise<void>) =>
309309
await page.keys([' ', 'b', 'y', 'e']);
310310
await page.pause(200);
311311

312-
// Cursor is at end. Select backward: "bye " then reference then " hi"
312+
// Select backward from end: " bye" (4 chars)
313313
for (let i = 0; i < 4; i++) {
314314
await page.keys(['Shift', 'ArrowLeft', 'Shift']);
315315
}
316316
const afterText = await page.getSelectedText();
317317
expect(afterText).toBe(' bye');
318318

319-
// One more should jump over the reference
319+
// One more jumps over the atomic reference
320320
await page.keys(['Shift', 'ArrowLeft', 'Shift']);
321321
await page.pause(100);
322322
const selected = await page.getSelectedText();
323-
expect(selected).toContain('John Smith');
323+
expect(selected).toContain('Jane Smith');
324324
expect(selected).toContain(' bye');
325325
})
326326
);
327327

328328
test(
329-
'shift+left then shift+right reversal deselects reference without flipping selection',
329+
'shift+left then shift+right reversal deselects correctly around reference',
330330
setupTest(async page => {
331331
await page.focusInput();
332332
await page.keys(['h', 'e', 'l', 'l', 'o', ' ']);
@@ -337,27 +337,121 @@ const setupTest = (testFn: (page: PromptInputTokenModePage) => Promise<void>) =>
337337
await page.keys([' ', 'w', 'o', 'r', 'l', 'd']);
338338
await page.pause(200);
339339

340-
// Place cursor in middle of "world" (3 chars from end)
340+
// Place cursor in middle of "world"
341341
await page.keys(['ArrowLeft', 'ArrowLeft', 'ArrowLeft']);
342342
await page.pause(100);
343343

344-
// Select backward past " wo", over reference, into "hello"
345-
for (let i = 0; i < 7; i++) {
344+
// Select backward: " wo" (3) + reference (1) + "hello " (6) = 10 presses
345+
for (let i = 0; i < 10; i++) {
346346
await page.keys(['Shift', 'ArrowLeft', 'Shift']);
347347
}
348348
await page.pause(100);
349349
const backwardSel = await page.getSelectedText();
350-
expect(backwardSel).toContain('John Smith');
350+
expect(backwardSel).toContain('hello');
351+
expect(backwardSel).toContain('Jane Smith');
351352

352-
// Now reverse with shift+right — deselect back through "hello " and the reference
353-
for (let i = 0; i < 7; i++) {
353+
// Reverse with shift+right — deselect everything
354+
for (let i = 0; i < 10; i++) {
354355
await page.keys(['Shift', 'ArrowRight', 'Shift']);
355356
}
356357
await page.pause(100);
357358

358-
// Selection should be collapsed or very small — not extending the wrong end
359359
const afterReverse = await page.getSelectedText();
360-
expect(afterReverse.length).toBeLessThanOrEqual(1);
360+
expect(afterReverse).toBe('');
361+
})
362+
);
363+
});
364+
365+
(isReact18 ? describe : describe.skip)('PromptInput token mode - delete key with references', () => {
366+
test(
367+
'delete key removes reference token ahead of cursor',
368+
setupTest(async page => {
369+
await page.focusInput();
370+
await page.keys(['@']);
371+
await page.pause(200);
372+
await page.keys(['ArrowDown', 'Enter']);
373+
await page.pause(200);
374+
await page.keys([' ', 'h', 'i']);
375+
await page.pause(100);
376+
377+
// Move cursor to start (before the reference)
378+
await page.keys(['Home']);
379+
await page.pause(100);
380+
381+
// Delete should remove the reference
382+
await page.keys(['Delete']);
383+
await page.pause(200);
384+
385+
const text = await page.getEditorText();
386+
expect(text.trim()).toBe('hi');
387+
expect(text).not.toContain('Jane Smith');
388+
})
389+
);
390+
391+
test(
392+
'backspace removes reference token behind cursor',
393+
setupTest(async page => {
394+
await page.focusInput();
395+
await page.keys(['h', 'i', ' ']);
396+
await page.keys(['@']);
397+
await page.pause(200);
398+
await page.keys(['ArrowDown', 'Enter']);
399+
await page.pause(200);
400+
401+
// Cursor is right after the reference — backspace removes it
402+
await page.keys(['Backspace']);
403+
await page.pause(200);
404+
405+
const text = await page.getEditorText();
406+
expect(text.trim()).toBe('hi');
407+
expect(text).not.toContain('Jane Smith');
408+
expect(await page.getCaretOffset()).toBe(3);
409+
})
410+
);
411+
});
412+
413+
(isReact18 ? describe : describe.skip)('PromptInput token mode - trigger dismissal', () => {
414+
test(
415+
'space on empty trigger dismisses it',
416+
setupTest(async page => {
417+
await page.focusInput();
418+
await page.keys(['h', 'i', ' ', '@']);
419+
await page.pause(200);
420+
await expect(page.isMenuOpen()).resolves.toBe(true);
421+
422+
await page.keys([' ']);
423+
await page.pause(300);
424+
425+
await expect(page.isMenuOpen()).resolves.toBe(false);
426+
const text = await page.getEditorText();
427+
expect(text).toContain('hi');
428+
expect(text).toContain('@');
429+
})
430+
);
431+
});
432+
433+
(isReact18 ? describe : describe.skip)('PromptInput token mode - multiple references', () => {
434+
test(
435+
'insert two references with text between them',
436+
setupTest(async page => {
437+
await page.focusInput();
438+
await page.keys(['@']);
439+
await page.pause(200);
440+
await page.keys(['ArrowDown', 'Enter']);
441+
await page.pause(200);
442+
443+
await page.keys([' ', 'a', 'n', 'd', ' ']);
444+
445+
await page.keys(['@']);
446+
await page.pause(200);
447+
// Select second option
448+
await page.keys(['ArrowDown', 'ArrowDown', 'Enter']);
449+
await page.pause(200);
450+
451+
const text = await page.getEditorText();
452+
expect(text).toContain('Jane Smith');
453+
expect(text).toContain('and');
454+
expect(text).toContain('Bob');
361455
})
362456
);
363457
});

src/prompt-input/__integ__/token-renderer.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ describe('Token Renderer (isolated)', () => {
6363
await clickButton(page, 'Add reference');
6464

6565
const editorText = await getEditorText(page);
66-
expect(editorText).toContain('⚡');
6766
expect(editorText).toContain('Alice');
6867

6968
const tokenState = await getTokenState(page);

src/prompt-input/__tests__/caret-controller.test.ts

Lines changed: 14 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
jest.mock('../styles.css.js', () => ({}), { virtual: true });
55

6+
import './jsdom-polyfills';
67
import {
78
calculateTokenPosition,
89
calculateTotalTokenLength,
@@ -783,11 +784,11 @@ describe('CaretController - additional branch coverage', () => {
783784
expect(window.getSelection()!.toString()).toBe('');
784785
});
785786

786-
test('does nothing when no selection object', () => {
787+
test('selectAll selects content even when element is not focused', () => {
787788
addParagraph(el, 'hello');
788789
controller.selectAll();
789-
// selectAll should work without throwing
790-
expect(window.getSelection()!.rangeCount).toBeGreaterThanOrEqual(0);
790+
const sel = window.getSelection()!;
791+
expect(sel.toString()).toBe('hello');
791792
});
792793
});
793794

@@ -912,8 +913,8 @@ describe('CaretController - additional branch coverage', () => {
912913
window.getSelection()?.addRange(range);
913914

914915
const pos = controller.getPosition();
915-
// Should be reference (1) + offset in after content
916-
expect(pos).toBeGreaterThanOrEqual(1);
916+
// Reference (1) + stripped offset in after-spot: raw offset 3 minus 1 for zero-width char = 2
917+
expect(pos).toBe(3);
917918
});
918919

919920
test('getPosition with cursor in trigger text node', () => {
@@ -2073,7 +2074,6 @@ describe('CaretController - defensive guard coverage', () => {
20732074
afterEach(() => {
20742075
document.body.innerHTML = '';
20752076
jest.restoreAllMocks();
2076-
delete (Range.prototype as any).getBoundingClientRect;
20772077
delete (HTMLElement.prototype as any).scrollIntoView;
20782078
});
20792079

@@ -2104,7 +2104,7 @@ describe('CaretController - defensive guard coverage', () => {
21042104
const mockRangeRect = { top: 150, bottom: 160, left: 0, right: 10 };
21052105

21062106
jest.spyOn(el, 'getBoundingClientRect').mockReturnValue(mockElementRect as DOMRect);
2107-
Range.prototype.getBoundingClientRect = jest.fn().mockReturnValue(mockRangeRect as DOMRect);
2107+
jest.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue(mockRangeRect as DOMRect);
21082108

21092109
const scrollSpy = jest.fn();
21102110
HTMLElement.prototype.scrollIntoView = scrollSpy;
@@ -2121,27 +2121,14 @@ describe('CaretController - defensive guard coverage', () => {
21212121
const mockRangeRect = { top: 150, bottom: 160, left: 0, right: 10 };
21222122

21232123
jest.spyOn(el, 'getBoundingClientRect').mockReturnValue(mockElementRect as DOMRect);
2124-
Range.prototype.getBoundingClientRect = jest.fn().mockReturnValue(mockRangeRect as DOMRect);
2124+
jest.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue(mockRangeRect as DOMRect);
21252125
const scrollSpy = jest.fn();
21262126
HTMLElement.prototype.scrollIntoView = scrollSpy;
21272127

21282128
controller.setPosition(2, 8);
21292129
expect(scrollSpy).toHaveBeenCalled();
21302130
});
21312131

2132-
test('setPosition scroll handles getBoundingClientRect throwing', () => {
2133-
addParagraph(el, 'hello');
2134-
el.focus();
2135-
2136-
Range.prototype.getBoundingClientRect = jest.fn().mockImplementation(() => {
2137-
throw new Error('not supported');
2138-
});
2139-
2140-
// Should not throw — caught by try/catch, and position should still be set
2141-
controller.setPosition(3);
2142-
expect(controller.getPosition()).toBe(3);
2143-
});
2144-
21452132
test('restore does nothing when element is not the active element', () => {
21462133
addParagraph(el, 'hello');
21472134
el.focus();
@@ -2480,7 +2467,6 @@ describe('CaretController - remaining uncovered branches', () => {
24802467
afterEach(() => {
24812468
document.body.innerHTML = '';
24822469
jest.restoreAllMocks();
2483-
delete (Range.prototype as any).getBoundingClientRect;
24842470
delete (HTMLElement.prototype as any).scrollIntoView;
24852471
});
24862472

@@ -2492,7 +2478,7 @@ describe('CaretController - remaining uncovered branches', () => {
24922478
const mockRangeRect = { top: 150, bottom: 160, left: 0, right: 10 };
24932479

24942480
jest.spyOn(el, 'getBoundingClientRect').mockReturnValue(mockElementRect as DOMRect);
2495-
Range.prototype.getBoundingClientRect = jest.fn().mockReturnValue(mockRangeRect as DOMRect);
2481+
jest.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue(mockRangeRect as DOMRect);
24962482
HTMLElement.prototype.scrollIntoView = jest.fn();
24972483

24982484
// Start at 0 (valid), end at 999 (beyond content)
@@ -2509,7 +2495,7 @@ describe('CaretController - remaining uncovered branches', () => {
25092495
const mockRangeRect = { top: 150, bottom: 160, left: 0, right: 10 };
25102496

25112497
jest.spyOn(el, 'getBoundingClientRect').mockReturnValue(mockElementRect as DOMRect);
2512-
Range.prototype.getBoundingClientRect = jest.fn().mockReturnValue(mockRangeRect as DOMRect);
2498+
jest.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue(mockRangeRect as DOMRect);
25132499
HTMLElement.prototype.scrollIntoView = jest.fn();
25142500

25152501
// Collapsed range (no end)
@@ -2699,10 +2685,12 @@ describe('CaretController - null textContent fallbacks', () => {
26992685

27002686
Object.defineProperty(textNode, 'textContent', { value: null, writable: true });
27012687

2688+
el.focus();
27022689
controller.positionAfterText(textNode);
2703-
// Should not throw — falls back to offset 0
2690+
// Falls back to offset 0 since textContent is null
27042691
const sel = window.getSelection()!;
2705-
expect(sel.rangeCount).toBeGreaterThanOrEqual(0);
2692+
expect(sel.rangeCount).toBe(1);
2693+
expect(sel.getRangeAt(0).startOffset).toBe(0);
27062694
});
27072695
});
27082696

0 commit comments

Comments
 (0)