[Bug] ChatComposerInput: inline tokens are lost on any external value update (deserialize is declared but never called)
- Dominant language
- TypeScript
- Stars
- 13.1k
- Forks
- 1.1k
- Avg merge
- 1d 14h
- Merged PRs (30d)
- 669
Description
### Description
`ChatComposerTrigger` declares:
```ts
/**
* Parse serialized tokens back into rendered tokens.
* Used when loading a previous message for editing.
*/
deserialize?: (value: string) => ChatComposerToken | null;
```
Nothing calls it. Code search for `deserialize` in this repo returns two files, and in `packages/core/src/Chat/ChatComposerInput.tsx` the only occurrence is the declaration itself (line 139 on `main`). `useTriggerMenu.tsx` never references it.
The effect that syncs the controlled value does this:
```ts
// ChatComposerInput.tsx:420 (main)
editable.textContent = controlledValue;
```
That assignment removes every `[data-astryx-token]` span, and `cleanupPortals()` then drops the matching portals. A token is a DOM span plus a React portal, and the `value` string carries neither, so there is nothing left to rebuild from. Any genuine external `value` update turns every inline chip into its raw serialized text.
The paths that hit this are ordinary ones: session switching, draft restore, prompt-history recall, and the "loading a previous message for editing" case named in the prop's own doc comment.
It also defeats a feature that is on by default. `pasteAsToken` collapses a paste over 200 characters into a `{ value: , label: "N lines, M chars" }` badge. After an external `value` write that badge is gone and the full pasted text sits in the input, which is the flooding the feature was added to stop.
**Expected**: after an external `value` update, serialized tokens render as tokens again. That is what `deserialize` is for. The paste token needs an equivalent, since it belongs to no trigger.
**Actual**: they become plain text and stay that way. Retyping is the only way back.
### Reproduction
Default configuration, no custom trigger:
```tsx
function Demo() {
const [drafts, setDrafts] = useState({ a: '', b: '' });
const [active, setActive] = useState<'a' | 'b'>('a');
return (
<>
setDrafts((d) => ({ ...d, [active]: v }))}
/>
setActive('b')}>draft B
setActive('a')}>draft A
);
}
```
1. Focus the input and paste about 500 characters. It collapses to a `500 chars` badge.
2. Click "draft B", then "draft A".
3. The badge is gone and all 500 characters are in the input.
The same three steps with a `triggers` entry whose `onSelect` returns a `ChatComposerToken` lose the chip the same way, with or without `deserialize` supplied.
### Astryx Version
`@astryxdesign/core@0.2.0`, also checked against current `main`.
### Environment
Electron 43.1.1, macOS 15. Nothing here is engine-specific; the loss happens in the `textContent` assignment.
### Related
#2473 (`ChatComposerInput` internal-authoritative state model) is aimed at this exact line and this exact tension. Its API sketch is text-only (`setValue` / `insertText` / `getText`), so as written it would move the string write without bringing tokens back. Token round-tripping belongs inside that state model rather than in a patch after it. Filing this separately so the constraint is recorded either way.
Contributor guide
Research direction
Start in packages/core/src/Chat/ChatComposerInput.tsx, at the deserialize declaration around line 139 and the controlled-value effect around line 420; compare its behavior with useTriggerMenu.tsx and the state-model discussion in #2473. Reproduce the draft-switching case with a paste token and a trigger token, then consider the design needed for external values to restore both token types without losing their portals.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 35/100