Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 50 additions & 14 deletions src/web-ui/src/flow_chat/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
// Ref so the queuedInput sync effect can read the latest value without it being a dep
const inputValueRef = useRef('');
const pendingLargePastesRef = useRef<PendingLargePasteMap>({});
const [pendingLargePastes, setPendingLargePastes] = useState<PendingLargePasteMap>({});
const composerMutationRevisionsRef = useRef(new Map<string, number>());
const isRestoringSessionDraftRef = useRef(false);
const sessionConflictRetryBaselinesRef = useRef(new Map<string, number>());
Expand Down Expand Up @@ -1758,7 +1759,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({

dispatchLocalInput({ type: 'SET_VALUE', payload: nextValue });
inputValueRef.current = nextValue;
pendingLargePastesRef.current = { ...nextPendingLargePastes };
const restoredPendingLargePastes = { ...nextPendingLargePastes };
pendingLargePastesRef.current = restoredPendingLargePastes;
setPendingLargePastes(restoredPendingLargePastes);
isRestoringSessionDraftRef.current = true;
try {
replaceContexts(nextContexts);
Expand Down Expand Up @@ -1822,6 +1825,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
markComposerMutation();
}
pendingLargePastesRef.current = nextPendingLargePastes;
setPendingLargePastes(nextPendingLargePastes);

const sessionId = effectiveTargetSessionIdRef.current;
if (sessionId) {
Expand Down Expand Up @@ -1961,28 +1965,55 @@ export const ChatInput: React.FC<ChatInputProps> = ({
replaceContexts,
]);

const allocateLargePastePlaceholder = useCallback((charCount: number, excluded?: string): string => {
const base = t('input.largePastePlaceholder', { count: charCount });
let suffix = largePasteCountersRef.current[charCount] ?? 0;
let placeholder: string;
do {
suffix += 1;
placeholder = suffix === 1 ? base : `${base} #${suffix}`;
} while (
placeholder !== excluded
&& Object.prototype.hasOwnProperty.call(pendingLargePastesRef.current, placeholder)
);
largePasteCountersRef.current[charCount] = suffix;
return placeholder;
}, [t]);

const createLargePastePlaceholder = useCallback((text: string): string | null => {
const charCount = getCharacterCount(text);
if (charCount <= CHAT_INPUT_CONFIG.largePaste.thresholdChars) {
return null;
}

const nextCounters = largePasteCountersRef.current;
const nextSuffix = (nextCounters[charCount] ?? 0) + 1;
nextCounters[charCount] = nextSuffix;

const base = t('input.largePastePlaceholder', {
count: charCount,
});
const placeholder = nextSuffix === 1 ? base : `${base} #${nextSuffix}`;

const placeholder = allocateLargePastePlaceholder(charCount);
replacePendingLargePastes({
...pendingLargePastesRef.current,
[placeholder]: text,
});

return placeholder;
}, [replacePendingLargePastes, t]);
}, [allocateLargePastePlaceholder, replacePendingLargePastes]);

const updateLargePaste = useCallback((placeholder: string, text: string): string => {
const currentText = pendingLargePastesRef.current[placeholder];
const charCount = getCharacterCount(text);
const nextPlaceholder = currentText !== undefined && getCharacterCount(currentText) === charCount
? placeholder
: allocateLargePastePlaceholder(charCount, placeholder);
const nextPendingLargePastes = { ...pendingLargePastesRef.current };
delete nextPendingLargePastes[placeholder];
nextPendingLargePastes[nextPlaceholder] = text;
replacePendingLargePastes(nextPendingLargePastes);
return nextPlaceholder;
}, [allocateLargePastePlaceholder, replacePendingLargePastes]);

const removeLargePaste = useCallback((placeholder: string) => {
if (!Object.prototype.hasOwnProperty.call(pendingLargePastesRef.current, placeholder)) return;
const nextPendingLargePastes = { ...pendingLargePastesRef.current };
delete nextPendingLargePastes[placeholder];
replacePendingLargePastes(nextPendingLargePastes);
}, [replacePendingLargePastes]);

const prunePendingLargePastes = useCallback((text: string) => {
const entries = Object.entries(pendingLargePastesRef.current);
Expand Down Expand Up @@ -2903,7 +2934,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({
// (EventHandlerModule sets queuedInput on failed turns), NOT for live typing.
// Restoring while the user is actively typing would overwrite their draft.
log.debug('Detected queuedInput, restoring message to input', { queuedInput });
clearPendingLargePastes();
// Keep the session-scoped paste map restored with this draft. Remote and
// detached submissions must still expand placeholders before transport.
dispatchInput({ type: 'SET_VALUE', payload: queuedInput });
inputValueRef.current = queuedInput;
if (richTextInputRef.current) {
Expand All @@ -2913,7 +2945,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({
}, [
derivedState?.queuedInput,
effectiveTargetSessionId,
clearPendingLargePastes,
dispatchInput,
]);

Expand Down Expand Up @@ -4428,7 +4459,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({
effectiveTargetSessionIdRef.current = newSessionId;
dispatchLocalInput({ type: 'SET_VALUE', payload: transferredDraft.value });
inputValueRef.current = transferredDraft.value;
pendingLargePastesRef.current = transferredDraft.pendingLargePastes;
const transferredPendingLargePastes = { ...transferredDraft.pendingLargePastes };
pendingLargePastesRef.current = transferredPendingLargePastes;
setPendingLargePastes(transferredPendingLargePastes);
isRestoringSessionDraftRef.current = true;
try {
replaceContexts(transferredDraft.contexts);
Expand Down Expand Up @@ -5711,6 +5744,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({
value={inputState.value}
onChange={handleInputChange}
onLargePaste={createLargePastePlaceholder}
pendingLargePastes={pendingLargePastes}
onUpdateLargePaste={updateLargePaste}
onRemoveLargePaste={removeLargePaste}
onKeyDown={handleKeyDown}
onCompositionStart={handleImeCompositionStart}
onCompositionEnd={handleImeCompositionEnd}
Expand Down
73 changes: 72 additions & 1 deletion src/web-ui/src/flow_chat/components/RichTextInput.scss
Original file line number Diff line number Diff line change
Expand Up @@ -303,10 +303,81 @@
}
}

.rich-text-large-paste {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: min(100%, 320px);
margin: 0 3px;
padding: 2px 6px 2px 8px;
border: 1px solid color-mix(in srgb, var(--bf-color-border-default) 72%, transparent);
border-radius: 10px;
background: color-mix(in srgb, var(--bf-color-surface-subtle) 82%, transparent);
color: var(--bf-color-content-secondary);
font-family: var(--bf-type-body-sm-font-family);
font-size: var(--bf-type-flow-control-font-size);
font-weight: var(--bf-type-label-sm-font-weight);
line-height: var(--bf-type-flow-support-line-height);
vertical-align: middle;
cursor: pointer;
user-select: none;
transition:
background-color 160ms ease,
border-color 160ms ease,
color 160ms ease;

&:hover {
border-color: color-mix(in srgb, var(--bf-color-accent-default) 42%, var(--bf-color-border-default));
background: color-mix(in srgb, var(--bf-color-accent-default) 8%, var(--bf-color-surface-subtle));
color: var(--bf-color-content-primary);
}

&:focus-visible {
outline: 2px solid color-mix(in srgb, var(--bf-color-accent-hover) 40%, transparent);
outline-offset: 1px;
}
}

.rich-text-large-paste__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.rich-text-large-paste__remove {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 16px;
height: 16px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: currentColor;
font-size: var(--bf-type-flow-section-title-font-size);
line-height: var(--bf-type-modifier-leading-none-line-height);
opacity: 0.6;
cursor: pointer;

&:hover,
&:focus-visible {
background: color-mix(in srgb, currentColor 12%, transparent);
opacity: 1;
}
}

.rich-text-large-paste-dialog__textarea {
min-height: 280px;
font-family: var(--bf-type-code-md-font-family);
}

@media (prefers-reduced-motion: reduce) {
.rich-text-placeholder,
.rich-text-tag-pill,
.rich-text-tag-pill__remove {
.rich-text-tag-pill__remove,
.rich-text-large-paste {
transition-duration: 0ms;
}
}
Loading
Loading