mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-15 21:00:45 +00:00
Compare commits
17 Commits
feat/input
...
feat/noz-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9145165e4 | ||
|
|
cbf947795f | ||
|
|
1d297e0c77 | ||
|
|
ea4695e3ef | ||
|
|
36eb22e6de | ||
|
|
7c6658c401 | ||
|
|
e52b9834a9 | ||
|
|
330e7aca38 | ||
|
|
4f336591cc | ||
|
|
4173c54e52 | ||
|
|
09fff48329 | ||
|
|
db8733047d | ||
|
|
f8ef6d478a | ||
|
|
4770657167 | ||
|
|
b5a8e7fb0a | ||
|
|
5a9bbc0e64 | ||
|
|
09784eec6c |
@@ -70,49 +70,3 @@
|
||||
Object.defineProperty(window, 'Date', { value: FrozenDate, writable: true });
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// Storybook has no microphone. Chromium's recognizer asks for one and for
|
||||
// Google's speech service, and the `not-allowed` or `network` it reports makes
|
||||
// the assistant hide its mic for the rest of the tab. This one starts
|
||||
// listening at once and hears only what a story passes to `hear`.
|
||||
(() => {
|
||||
class StorySpeechRecognition extends EventTarget {
|
||||
static listening = null;
|
||||
lang = 'en-US';
|
||||
continuous = false;
|
||||
interimResults = false;
|
||||
onstart = null;
|
||||
onresult = null;
|
||||
onerror = null;
|
||||
onend = null;
|
||||
|
||||
start() {
|
||||
StorySpeechRecognition.listening = this;
|
||||
setTimeout(() => this.onstart?.());
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (StorySpeechRecognition.listening === this) {
|
||||
StorySpeechRecognition.listening = null;
|
||||
}
|
||||
setTimeout(() => this.onend?.());
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
/** An interim result: the words so far, still listening. */
|
||||
hear(transcript) {
|
||||
const result = Object.assign([{ transcript, confidence: 1 }], {
|
||||
isFinal: false,
|
||||
});
|
||||
this.onresult?.({ resultIndex: 0, results: [result] });
|
||||
}
|
||||
}
|
||||
|
||||
window.SpeechRecognition = StorySpeechRecognition;
|
||||
window.webkitSpeechRecognition = StorySpeechRecognition;
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -1,37 +1,6 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
border-left: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.resizeHandle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 10;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 1px;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
&:hover::after {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
.panel:global(.floating-panel) {
|
||||
background: var(--l2-background);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
}
|
||||
|
||||
.header {
|
||||
@@ -41,7 +10,7 @@
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
flex-shrink: 0;
|
||||
background: var(--l1-background);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.title {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useLayoutEffect, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { matchRoute } from 'lib/router/matchRoute';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { History, Maximize2, Plus, X } from '@signozhq/icons';
|
||||
import { History, Maximize2, Minus, Plus, X } from '@signozhq/icons';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { BOTTOM_STRIP_HEIGHT } from 'container/BottomStrip';
|
||||
import { FloatingPanel } from 'periscope/components/FloatingPanel';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
|
||||
@@ -22,6 +24,22 @@ import styles from './AIAssistantPanel.module.scss';
|
||||
const AI_ASSISTANT_PANEL_OPEN_CLASS = 'ai-assistant-panel-open';
|
||||
const AI_ASSISTANT_PANEL_WIDTH_VAR = '--ai-assistant-panel-width';
|
||||
|
||||
const PANEL_WIDTH = 420;
|
||||
const PANEL_HEIGHT = 601;
|
||||
const PANEL_MIN_WIDTH = 380;
|
||||
const PANEL_MIN_HEIGHT = 500;
|
||||
const PANEL_MARGIN = 8;
|
||||
const PANEL_RESIZE_EDGES = {
|
||||
top: true,
|
||||
right: true,
|
||||
bottom: true,
|
||||
left: true,
|
||||
topRight: false,
|
||||
bottomRight: false,
|
||||
bottomLeft: false,
|
||||
topLeft: false,
|
||||
};
|
||||
|
||||
export default function AIAssistantPanel(): JSX.Element | null {
|
||||
const { pathname } = useAppLocation();
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
@@ -72,11 +90,6 @@ export default function AIAssistantPanel(): JSX.Element | null {
|
||||
setShowHistory(false);
|
||||
}, []);
|
||||
|
||||
// ── Resize logic ──────────────────────────────────────────────────────────
|
||||
const [panelWidth, setPanelWidth] = useState(380);
|
||||
const dragStartX = useRef(0);
|
||||
const dragStartWidth = useRef(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const shouldOffsetChatSupport = isOpen && !isFullScreenPage;
|
||||
|
||||
@@ -88,7 +101,7 @@ export default function AIAssistantPanel(): JSX.Element | null {
|
||||
if (shouldOffsetChatSupport) {
|
||||
document.body.style.setProperty(
|
||||
AI_ASSISTANT_PANEL_WIDTH_VAR,
|
||||
`${panelWidth}px`,
|
||||
`${PANEL_WIDTH}px`,
|
||||
);
|
||||
} else {
|
||||
document.body.style.removeProperty(AI_ASSISTANT_PANEL_WIDTH_VAR);
|
||||
@@ -98,46 +111,40 @@ export default function AIAssistantPanel(): JSX.Element | null {
|
||||
document.body.classList.remove(AI_ASSISTANT_PANEL_OPEN_CLASS);
|
||||
document.body.style.removeProperty(AI_ASSISTANT_PANEL_WIDTH_VAR);
|
||||
};
|
||||
}, [isFullScreenPage, isOpen, panelWidth]);
|
||||
|
||||
const handleResizeMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragStartX.current = e.clientX;
|
||||
dragStartWidth.current = panelWidth;
|
||||
|
||||
const onMouseMove = (ev: MouseEvent): void => {
|
||||
// Panel is on the right; dragging left (lower clientX) increases width
|
||||
const delta = dragStartX.current - ev.clientX;
|
||||
const next = Math.min(Math.max(dragStartWidth.current + delta, 380), 800);
|
||||
setPanelWidth(next);
|
||||
};
|
||||
|
||||
const onMouseUp = (): void => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
},
|
||||
[panelWidth],
|
||||
);
|
||||
}, [isFullScreenPage, isOpen]);
|
||||
|
||||
if (!isOpen || isFullScreenPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const panelHeight = Math.max(
|
||||
PANEL_MIN_HEIGHT,
|
||||
Math.min(
|
||||
PANEL_HEIGHT,
|
||||
window.innerHeight - BOTTOM_STRIP_HEIGHT - 2 * PANEL_MARGIN,
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<VariantContext.Provider value="panel">
|
||||
<div className={styles.panel} style={{ width: panelWidth }}>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div className={styles.resizeHandle} onMouseDown={handleResizeMouseDown} />
|
||||
<div className={styles.header}>
|
||||
<FloatingPanel
|
||||
isOpen
|
||||
className={styles.panel}
|
||||
width={PANEL_WIDTH}
|
||||
height={panelHeight}
|
||||
minWidth={PANEL_MIN_WIDTH}
|
||||
minHeight={PANEL_MIN_HEIGHT}
|
||||
enableResizing={PANEL_RESIZE_EDGES}
|
||||
defaultPosition={{
|
||||
x: window.innerWidth - PANEL_WIDTH - PANEL_MARGIN,
|
||||
// Never above the viewport, where the header could not be dragged back.
|
||||
y: Math.max(
|
||||
0,
|
||||
window.innerHeight - BOTTOM_STRIP_HEIGHT - PANEL_MARGIN - panelHeight,
|
||||
),
|
||||
}}
|
||||
>
|
||||
<div className={`${styles.header} floating-panel__drag-handle`}>
|
||||
<div className={`${styles.title} noz-wave`}>
|
||||
<Noz size={18} />
|
||||
<span>Noz</span>
|
||||
@@ -170,6 +177,19 @@ export default function AIAssistantPanel(): JSX.Element | null {
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Minimize">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon
|
||||
color="secondary"
|
||||
onClick={closeDrawer}
|
||||
aria-label="Minimize panel"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Open full screen">
|
||||
<Button
|
||||
disabledTooltip="Start a conversation first"
|
||||
@@ -207,7 +227,7 @@ export default function AIAssistantPanel(): JSX.Element | null {
|
||||
<ConversationView conversationId={activeConversationId} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</FloatingPanel>
|
||||
</VariantContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,38 @@
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
// The full page sits the thread in a faint pane whose bottom edge runs behind the input.
|
||||
&.fullPage {
|
||||
position: relative;
|
||||
// Lets the pane sit behind the thread without dropping under the page.
|
||||
isolation: isolate;
|
||||
padding: 13px 13px 0 1px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
inset: 12px 12px 32px 0;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-3);
|
||||
background: var(--l2-background-60);
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Fades the pane's foot into the page on either side of the input.
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 46px;
|
||||
background: linear-gradient(to bottom, transparent, var(--l1-background));
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
@@ -34,7 +66,18 @@
|
||||
border-top: 1px solid var(--l1-border);
|
||||
|
||||
&.compact {
|
||||
padding: 8px;
|
||||
padding: 0;
|
||||
// Same colour as the panel's outline.
|
||||
border-top-color: var(--l2-border);
|
||||
}
|
||||
|
||||
&.fullPage {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(768px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 0 0 var(--spacing-6);
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
|
||||
@@ -26,6 +26,7 @@ export default function ConversationView({
|
||||
}: ConversationViewProps): JSX.Element {
|
||||
const variant = useVariant();
|
||||
const isCompact = variant === 'panel';
|
||||
const isFullPage = variant === 'page';
|
||||
const location = useAppLocation();
|
||||
|
||||
const conversation = useAIAssistantStore(
|
||||
@@ -110,30 +111,72 @@ export default function ConversationView({
|
||||
);
|
||||
|
||||
// Wall-clock timestamp of the current streaming start, used to compute
|
||||
// `secondsSinceStart` on Cancel clicked. Cleared whenever streaming ends.
|
||||
const streamStartedAtRef = useRef<number | null>(null);
|
||||
// `secondsSinceStart` on Cancel clicked and to keep the input's sweep in step
|
||||
// across a remount. Cleared whenever streaming ends.
|
||||
const [streamStartedAt, setStreamStartedAt] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (!isStreamingHere) {
|
||||
streamStartedAtRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (streamStartedAtRef.current === null) {
|
||||
streamStartedAtRef.current = Date.now();
|
||||
}
|
||||
setStreamStartedAt((prev) => (isStreamingHere ? (prev ?? Date.now()) : null));
|
||||
}, [isStreamingHere]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
const startedAt = streamStartedAtRef.current;
|
||||
void logEvent(AIAssistantEvents.CancelClicked, {
|
||||
threadId: analyticsCtx.threadId,
|
||||
secondsSinceStart:
|
||||
startedAt !== null ? Math.round((Date.now() - startedAt) / 1000) : null,
|
||||
streamStartedAt !== null
|
||||
? Math.round((Date.now() - streamStartedAt) / 1000)
|
||||
: null,
|
||||
});
|
||||
cancelStream(conversationId);
|
||||
}, [cancelStream, conversationId, analyticsCtx.threadId]);
|
||||
}, [cancelStream, conversationId, analyticsCtx.threadId, streamStartedAt]);
|
||||
|
||||
const messages = conversation?.messages ?? [];
|
||||
const showDisclaimer = messages.length > 0;
|
||||
|
||||
// When this chat's first message went out; drives the empty state's exit across a remount.
|
||||
const firstMessageId = messages[0]?.id;
|
||||
const isLoadingHere = isLoadingThread || Boolean(conversation?.isHydrating);
|
||||
const [firstSend, setFirstSend] = useState<{
|
||||
conversationId: string;
|
||||
messageCount: number;
|
||||
firstMessageId: string | undefined;
|
||||
isLoading: boolean;
|
||||
at: number | null;
|
||||
}>({
|
||||
conversationId,
|
||||
messageCount: messages.length,
|
||||
firstMessageId,
|
||||
isLoading: isLoadingHere,
|
||||
at: null,
|
||||
});
|
||||
if (
|
||||
firstSend.conversationId !== conversationId ||
|
||||
firstSend.messageCount !== messages.length ||
|
||||
firstSend.isLoading !== isLoadingHere
|
||||
) {
|
||||
// A loaded thread arrives while loading; a send adds the user's own first message.
|
||||
const isFirstSend =
|
||||
firstSend.conversationId === conversationId &&
|
||||
firstSend.messageCount === 0 &&
|
||||
!firstSend.isLoading &&
|
||||
messages.length === 1 &&
|
||||
messages[0].role === 'user' &&
|
||||
isStreamingHere;
|
||||
// Renaming to the server's thread id keeps the first message; another chat starts clean.
|
||||
const isSameChat =
|
||||
firstSend.firstMessageId !== undefined &&
|
||||
firstSend.firstMessageId === firstMessageId;
|
||||
let at = isSameChat ? firstSend.at : null;
|
||||
if (isFirstSend) {
|
||||
at = Date.now();
|
||||
}
|
||||
setFirstSend({
|
||||
conversationId,
|
||||
messageCount: messages.length,
|
||||
firstMessageId,
|
||||
isLoading: isLoadingHere,
|
||||
at,
|
||||
});
|
||||
}
|
||||
const inputDisabled =
|
||||
isStreamingHere ||
|
||||
isLoadingThread ||
|
||||
@@ -150,8 +193,12 @@ export default function ConversationView({
|
||||
inputDisabledTooltip = 'Answer the pending question first';
|
||||
}
|
||||
|
||||
const conversationClass = cx(styles.conversation, {
|
||||
[styles.fullPage]: isFullPage,
|
||||
});
|
||||
const inputWrapperClass = cx(styles.inputWrapper, {
|
||||
[styles.compact]: isCompact,
|
||||
[styles.fullPage]: isFullPage,
|
||||
});
|
||||
const disclaimerClass = cx(styles.disclaimer, {
|
||||
[styles.compact]: isCompact,
|
||||
@@ -165,7 +212,7 @@ export default function ConversationView({
|
||||
const isHydrating = Boolean(conversation?.isHydrating);
|
||||
if ((isLoadingThread || isHydrating) && messages.length === 0) {
|
||||
return (
|
||||
<div className={styles.conversation}>
|
||||
<div className={conversationClass}>
|
||||
<ConversationSkeleton />
|
||||
<div className={inputWrapperClass}>
|
||||
<ChatInput
|
||||
@@ -182,12 +229,13 @@ export default function ConversationView({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.conversation}>
|
||||
<div className={conversationClass}>
|
||||
<VirtualizedMessages
|
||||
key={conversationId}
|
||||
conversationId={conversationId}
|
||||
messages={messages}
|
||||
isStreaming={isStreamingHere}
|
||||
firstSendAt={firstSend.at}
|
||||
onSendSuggestedPrompt={(text): void => {
|
||||
handleSend(
|
||||
text,
|
||||
@@ -209,6 +257,8 @@ export default function ConversationView({
|
||||
disabled={inputDisabled}
|
||||
disabledTooltip={inputDisabledTooltip}
|
||||
isStreaming={isStreamingHere}
|
||||
streamStartedAt={streamStartedAt}
|
||||
showNozPeek={messages.length === 0 && !isStreamingHere}
|
||||
autoContexts={autoContexts}
|
||||
onDismissAutoContext={handleDismissAutoContext}
|
||||
/>
|
||||
|
||||
@@ -3,24 +3,32 @@
|
||||
.input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: var(--l1-background);
|
||||
border-radius: var(--radius-2);
|
||||
padding: 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-3);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
position: relative;
|
||||
|
||||
// The panel already frames it.
|
||||
&.compact {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px 10px 0;
|
||||
}
|
||||
|
||||
.contextTags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px 10px 0;
|
||||
}
|
||||
|
||||
.contextTag {
|
||||
@@ -91,86 +99,78 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.composer {
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: 4px;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
|
||||
// Scope the focus ring to the textarea row only — the surrounding
|
||||
// chrome (context chips, "Add Context", mic, send) sits outside this
|
||||
// element and stays unaffected when the cursor enters the textarea.
|
||||
&:focus-within {
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 1px
|
||||
color-mix(in srgb, var(--accent-primary), transparent 70%);
|
||||
}
|
||||
position: relative;
|
||||
padding: 9px 16px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
padding: 4px 10px 8px;
|
||||
}
|
||||
|
||||
.leftActions,
|
||||
.rightActions {
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
--button-height: 28px;
|
||||
--button-width: 28px;
|
||||
--button-max-width: 28px;
|
||||
--button-border-radius: 9999px;
|
||||
|
||||
.attachBtn {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.contextBtn {
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
border-style: dashed !important;
|
||||
padding-inline: 12px !important;
|
||||
// Send stays a solid grey until there is something to send.
|
||||
[data-color='primary'][aria-disabled='true'] {
|
||||
--button-primary-background-color: var(--l3-background);
|
||||
--button-primary-solid-foreground: var(--l2-foreground);
|
||||
--button-disabled-opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
color: var(--l1-foreground);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.005em;
|
||||
overflow-y: auto;
|
||||
font-family: inherit;
|
||||
// ComposerCaret draws the caret.
|
||||
caret-color: transparent;
|
||||
@include scrollbar(0.2rem);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
color: var(--l2-foreground);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&::selection {
|
||||
background: var(--accent-primary);
|
||||
color: var(--l1-background);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.2;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
caret-color: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.charWarning {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
margin: 0 10px;
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
@@ -180,24 +180,9 @@
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.sendBtn {
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-2);
|
||||
|
||||
&.stop {
|
||||
background: var(--accent-cherry) !important;
|
||||
border-color: var(--accent-cherry) !important;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contextPopover {
|
||||
width: 480px !important;
|
||||
max-width: min(92vw, 480px);
|
||||
margin-left: 16px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l1-background);
|
||||
@@ -338,75 +323,3 @@
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.micRecording {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--l2-background);
|
||||
border-radius: 999px;
|
||||
padding: 2px;
|
||||
gap: 0;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
--button-icon-size: 12px;
|
||||
|
||||
> :last-child {
|
||||
--button-icon-size: 9px;
|
||||
--button-variant-ghost-color: var(--accent-cherry);
|
||||
--button-variant-ghost-hover-color: var(--accent-cherry);
|
||||
}
|
||||
}
|
||||
|
||||
.micWaves {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
width: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--l1-foreground);
|
||||
animation: voiceWave 0.9s ease-in-out infinite;
|
||||
|
||||
&:nth-child(1) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
&:nth-child(4) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
&:nth-child(5) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
&:nth-child(6) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
&:nth-child(7) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
&:nth-child(8) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes voiceWave {
|
||||
0%,
|
||||
100% {
|
||||
height: 2px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
height: 12px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,8 @@ import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import type { UploadFile } from 'antd';
|
||||
import getSessionStorage from 'api/browser/sessionstorage/get';
|
||||
import setSessionStorage from 'api/browser/sessionstorage/set';
|
||||
import {
|
||||
getListDashboardsForUserV2QueryKey,
|
||||
useListDashboardsForUserV2,
|
||||
@@ -31,34 +28,29 @@ import type {
|
||||
ListRulesV3200,
|
||||
ListRulesV3Params,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useQueryService } from 'hooks/useQueryService';
|
||||
// eslint-disable-next-line
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import {
|
||||
AIAssistantEvents,
|
||||
VoiceInputSource,
|
||||
getBrowserInfo,
|
||||
} from '../../events';
|
||||
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
|
||||
import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
|
||||
import { MessageAttachment } from '../../types';
|
||||
import { useVariant } from '../../VariantContext';
|
||||
import { MessageContext } from '../../../../api/ai-assistant/chat';
|
||||
import {
|
||||
Mic,
|
||||
Plus,
|
||||
ArrowUp,
|
||||
AtSign,
|
||||
Search,
|
||||
Send,
|
||||
Square,
|
||||
TriangleAlert,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import styles from './ChatInput.module.scss';
|
||||
import ComposerCaret from './ComposerCaret/ComposerCaret';
|
||||
import ContextPickerEmptyState from './ContextPickerEmptyState';
|
||||
import EdgeSweep from './EdgeSweep/EdgeSweep';
|
||||
import NozPeek from './NozPeek/NozPeek';
|
||||
import {
|
||||
CONTEXT_CATEGORIES,
|
||||
CONTEXT_CATEGORY_ICONS,
|
||||
@@ -75,6 +67,10 @@ interface ChatInputProps {
|
||||
disabled?: boolean;
|
||||
disabledTooltip?: string;
|
||||
isStreaming?: boolean;
|
||||
/** When the current stream began; drives the top-edge sweep. */
|
||||
streamStartedAt?: number | null;
|
||||
/** Noz peeks over the top edge; meant for an empty conversation. */
|
||||
showNozPeek?: boolean;
|
||||
/**
|
||||
* URL-derived `source: 'auto'` contexts representing the page the user is
|
||||
* currently looking at. Rendered as chips alongside the user's `@`-mention
|
||||
@@ -171,8 +167,6 @@ const WARNING_THRESHOLD = 15000;
|
||||
// squeezed in tighter container variants (e.g. the floating panel).
|
||||
const TEXTAREA_MAX_HEIGHT_PX = 200;
|
||||
const HOME_SERVICES_INTERVAL = 30 * 60 * 1000;
|
||||
/** sessionStorage key for the "voice input failed this tab" flag. */
|
||||
const VOICE_UNAVAILABLE_KEY = 'ai-assistant-voice-unavailable';
|
||||
/**
|
||||
* The picker filters client-side, so it pulls one large page instead of
|
||||
* paginating. Shared with `getQueryData` below — the params are part of the
|
||||
@@ -243,12 +237,15 @@ export default function ChatInput({
|
||||
disabled,
|
||||
disabledTooltip,
|
||||
isStreaming = false,
|
||||
streamStartedAt = null,
|
||||
showNozPeek = false,
|
||||
autoContexts,
|
||||
onDismissAutoContext,
|
||||
}: ChatInputProps): JSX.Element {
|
||||
const { selectedTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const isCompact = useVariant() === 'panel';
|
||||
const [text, setText] = useState('');
|
||||
const [pendingFiles, setPendingFiles] = useState<UploadFile[]>([]);
|
||||
const [selectedContexts, setSelectedContexts] = useState<
|
||||
@@ -288,8 +285,6 @@ export default function ChatInput({
|
||||
endTime: now,
|
||||
};
|
||||
});
|
||||
// Stores the already-committed final text so interim results don't overwrite it
|
||||
const committedTextRef = useRef('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const inputRootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -343,7 +338,6 @@ export default function ChatInput({
|
||||
const next =
|
||||
text.slice(0, mentionRange.start) + text.slice(mentionRange.end);
|
||||
setText(next);
|
||||
committedTextRef.current = next;
|
||||
setMentionRange(null);
|
||||
}
|
||||
},
|
||||
@@ -356,7 +350,6 @@ export default function ChatInput({
|
||||
(prompt: string) => {
|
||||
const next = capText(prompt);
|
||||
setText(next);
|
||||
committedTextRef.current = next;
|
||||
setMentionRange(null);
|
||||
setPickerSearchQuery('');
|
||||
setIsContextPickerOpen(false);
|
||||
@@ -497,7 +490,6 @@ export default function ChatInput({
|
||||
contexts.length > 0 ? contexts : undefined,
|
||||
);
|
||||
setText('');
|
||||
committedTextRef.current = '';
|
||||
setPendingFiles([]);
|
||||
setSelectedContexts([]);
|
||||
textareaRef.current?.focus();
|
||||
@@ -532,209 +524,6 @@ export default function ChatInput({
|
||||
[],
|
||||
);
|
||||
|
||||
// ── Voice input ────────────────────────────────────────────────────────────
|
||||
|
||||
const analyticsCtx = useAIAssistantAnalyticsContext();
|
||||
// Captured at the start of a voice session, consumed when it ends.
|
||||
// Tracks both the trigger (button vs. PTT shortcut) and the wall-clock
|
||||
// start time so we can attribute `durationMs` on the Voice input used
|
||||
// event regardless of which control ended the session.
|
||||
const voiceStartedAtRef = useRef<number | null>(null);
|
||||
const voiceSourceRef = useRef<VoiceInputSource | null>(null);
|
||||
// Set to true after a `network`, `not-allowed`, or `not-supported` failure
|
||||
// so we hide the mic button for the rest of the tab session — silent
|
||||
// retries don't help, and Chromium derivatives without the Google Speech
|
||||
// API key always fail with `network` no matter how many times the user
|
||||
// clicks. Persisted to sessionStorage so a page reload doesn't surface the
|
||||
// button again (closing the tab still resets, in case the user fixed
|
||||
// permissions or switched browsers).
|
||||
const [voiceUnavailable, setVoiceUnavailable] = useState(
|
||||
() => getSessionStorage(VOICE_UNAVAILABLE_KEY) === 'true',
|
||||
);
|
||||
const markVoiceUnavailable = useCallback((): void => {
|
||||
setVoiceUnavailable(true);
|
||||
setSessionStorage(VOICE_UNAVAILABLE_KEY, 'true');
|
||||
}, []);
|
||||
|
||||
const {
|
||||
isListening,
|
||||
isSupported,
|
||||
permission: micPermission,
|
||||
start,
|
||||
discard,
|
||||
} = useSpeechRecognition({
|
||||
onTranscript: (transcriptText, isFinal) => {
|
||||
if (isFinal) {
|
||||
// Commit: append to whatever the user has already typed
|
||||
const separator = committedTextRef.current ? ' ' : '';
|
||||
const next = capText(committedTextRef.current + separator + transcriptText);
|
||||
committedTextRef.current = next;
|
||||
setText(next);
|
||||
} else {
|
||||
// Interim: live preview appended to committed text, not yet persisted
|
||||
const separator = committedTextRef.current ? ' ' : '';
|
||||
setText(capText(committedTextRef.current + separator + transcriptText));
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
// Guard against double-fire: Chrome can fire `onerror` more than
|
||||
// once per session when `continuous = true` (it retries internally
|
||||
// before giving up). Only fire the analytics event for the first
|
||||
// error in a given session — voiceSourceRef being null means we've
|
||||
// already handled it.
|
||||
const source = voiceSourceRef.current;
|
||||
if (source === null) {
|
||||
return;
|
||||
}
|
||||
voiceStartedAtRef.current = null;
|
||||
voiceSourceRef.current = null;
|
||||
void logEvent(AIAssistantEvents.VoiceInputFailed, {
|
||||
...analyticsCtx,
|
||||
...getBrowserInfo(),
|
||||
source,
|
||||
errorType: error,
|
||||
});
|
||||
if (error === 'network') {
|
||||
markVoiceUnavailable();
|
||||
toast.error('Voice input unavailable in this browser', {
|
||||
description:
|
||||
'This browser cannot reach the speech recognition service. Try Google Chrome or Microsoft Edge.',
|
||||
});
|
||||
} else if (error === 'not-allowed') {
|
||||
markVoiceUnavailable();
|
||||
toast.error('Microphone access denied', {
|
||||
description:
|
||||
'Grant microphone permission in your browser settings to use voice input.',
|
||||
});
|
||||
} else if (error === 'not-supported') {
|
||||
markVoiceUnavailable();
|
||||
toast.error('Voice input is not supported in this browser.');
|
||||
}
|
||||
// `no-speech` is benign (just silence) — don't toast or hide.
|
||||
},
|
||||
});
|
||||
|
||||
const showMic = isSupported && micPermission !== 'denied' && !voiceUnavailable;
|
||||
|
||||
const startVoiceInput = useCallback(
|
||||
(source: VoiceInputSource) => {
|
||||
// Defense in depth: the button is hidden when `voiceUnavailable` is
|
||||
// true, but the PTT shortcut listener can still call us. Bailing here
|
||||
// keeps a single source of truth and prevents repeat `Voice input
|
||||
// failed` events in the same session.
|
||||
if (voiceUnavailable) {
|
||||
return;
|
||||
}
|
||||
voiceStartedAtRef.current = Date.now();
|
||||
voiceSourceRef.current = source;
|
||||
start();
|
||||
},
|
||||
[start, voiceUnavailable],
|
||||
);
|
||||
|
||||
const fireVoiceInputEvent = useCallback(
|
||||
(outcome: 'sent' | 'discarded') => {
|
||||
const startedAt = voiceStartedAtRef.current;
|
||||
const source = voiceSourceRef.current;
|
||||
voiceStartedAtRef.current = null;
|
||||
voiceSourceRef.current = null;
|
||||
if (startedAt === null || source === null) {
|
||||
return;
|
||||
}
|
||||
void logEvent(AIAssistantEvents.VoiceInputUsed, {
|
||||
...analyticsCtx,
|
||||
...getBrowserInfo(),
|
||||
source,
|
||||
outcome,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
},
|
||||
[analyticsCtx],
|
||||
);
|
||||
|
||||
// Stop recording and immediately send whatever is in the textarea.
|
||||
const handleStopAndSend = useCallback(async () => {
|
||||
// Promote the displayed text (interim included) to committed so handleSend sees it.
|
||||
committedTextRef.current = capText(text);
|
||||
// Stop recognition without triggering onTranscript again (would double-append).
|
||||
discard();
|
||||
fireVoiceInputEvent('sent');
|
||||
await handleSend();
|
||||
}, [text, discard, handleSend, capText, fireVoiceInputEvent]);
|
||||
|
||||
// Stop recording and revert the textarea to what it was before voice started.
|
||||
const handleDiscard = useCallback(() => {
|
||||
discard();
|
||||
fireVoiceInputEvent('discarded');
|
||||
setText(committedTextRef.current);
|
||||
textareaRef.current?.focus();
|
||||
}, [discard, fireVoiceInputEvent]);
|
||||
|
||||
// ── Push-to-talk (Cmd/Ctrl + Shift + Space) ────────────────────────────────
|
||||
// Hold the combo to record; release Space to submit. We track which key
|
||||
// triggered PTT in a ref so a late-released modifier (Cmd/Shift) doesn't
|
||||
// accidentally stop the session. Auto-repeat is suppressed via a
|
||||
// "session active" ref so a held key only calls `start()` once.
|
||||
const pttActiveRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!isSupported || micPermission === 'denied' || voiceUnavailable) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
const isComboKey =
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
e.shiftKey &&
|
||||
(e.code === 'Space' || e.key === ' ');
|
||||
if (!isComboKey || disabled || isStreaming) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
if (pttActiveRef.current) {
|
||||
return; // ignore auto-repeat
|
||||
}
|
||||
pttActiveRef.current = true;
|
||||
startVoiceInput(VoiceInputSource.Shortcut);
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent): void => {
|
||||
if (!pttActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
// End on the *first* released key in the combo. macOS browsers
|
||||
// frequently swallow keyup of regular keys (incl. Space) while
|
||||
// Cmd is held, so we can't rely on Space-up alone — releasing
|
||||
// Cmd/Ctrl/Shift must also stop the session.
|
||||
const isComboKey =
|
||||
e.code === 'Space' ||
|
||||
e.key === ' ' ||
|
||||
e.key === 'Meta' ||
|
||||
e.key === 'Control' ||
|
||||
e.key === 'Shift';
|
||||
if (!isComboKey) {
|
||||
return;
|
||||
}
|
||||
pttActiveRef.current = false;
|
||||
e.preventDefault();
|
||||
void handleStopAndSend();
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
return (): void => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, [
|
||||
isSupported,
|
||||
micPermission,
|
||||
voiceUnavailable,
|
||||
disabled,
|
||||
isStreaming,
|
||||
startVoiceInput,
|
||||
handleStopAndSend,
|
||||
]);
|
||||
|
||||
// Each list hook fetches only when its picker tab is actively shown,
|
||||
// AND treats already-cached data as never stale (`staleTime: Infinity`)
|
||||
// so an open with a populated cache doesn't trigger a background
|
||||
@@ -900,7 +689,12 @@ export default function ChatInput({
|
||||
const showTextWarning = currentLength >= WARNING_THRESHOLD;
|
||||
|
||||
return (
|
||||
<div className={styles.input} ref={inputRootRef}>
|
||||
<div
|
||||
className={cx(styles.input, { [styles.compact]: isCompact })}
|
||||
ref={inputRootRef}
|
||||
>
|
||||
<EdgeSweep startedAt={streamStartedAt} />
|
||||
{showNozPeek && <NozPeek />}
|
||||
{pendingFiles.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
{pendingFiles.map((f) => (
|
||||
@@ -983,20 +777,19 @@ export default function ChatInput({
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={styles.textarea}
|
||||
placeholder="Ask anything… (Shift+Enter for new line)"
|
||||
placeholder="Ask Noz…"
|
||||
value={text}
|
||||
onChange={(e): void => {
|
||||
const next = capText(e.target.value);
|
||||
setText(next);
|
||||
// Keep committed text in sync when the user edits manually
|
||||
committedTextRef.current = next;
|
||||
syncContextPickerFromText(next, e.target.selectionStart ?? next.length);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
maxLength={MAX_INPUT_LENGTH}
|
||||
rows={3}
|
||||
rows={2}
|
||||
/>
|
||||
<ComposerCaret textareaRef={textareaRef} value={text} />
|
||||
</div>
|
||||
{showTextWarning && (
|
||||
<div className={styles.charWarning} role="status">
|
||||
@@ -1009,7 +802,7 @@ export default function ChatInput({
|
||||
)}
|
||||
|
||||
<div className={styles.footer}>
|
||||
<div className={styles.leftActions}>
|
||||
<div className={styles.actions}>
|
||||
<Popover
|
||||
open={isContextPickerOpen}
|
||||
onOpenChange={(open): void => {
|
||||
@@ -1020,22 +813,25 @@ export default function ChatInput({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
disabledTooltip={disabledTooltip}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
disabled={disabled}
|
||||
onClick={(): void => {
|
||||
setActiveContextCategory('Dashboards');
|
||||
setPickerSearchQuery('');
|
||||
}}
|
||||
prefix={<Plus size={10} />}
|
||||
>
|
||||
Add Context
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<Tooltip title="Add context">
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
disabledTooltip={disabledTooltip}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
disabled={disabled}
|
||||
onClick={(): void => {
|
||||
setActiveContextCategory('Dashboards');
|
||||
setPickerSearchQuery('');
|
||||
}}
|
||||
aria-label="Add context"
|
||||
>
|
||||
<AtSign size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</Tooltip>
|
||||
<PopoverContent
|
||||
className={styles.contextPopover}
|
||||
side="top"
|
||||
@@ -1164,82 +960,18 @@ export default function ChatInput({
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className={styles.rightActions}>
|
||||
{showMic &&
|
||||
(isListening ? (
|
||||
<div
|
||||
className={styles.micRecording}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="Recording voice input"
|
||||
>
|
||||
<Tooltip title="Discard recording">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="md"
|
||||
icon
|
||||
color="secondary"
|
||||
onClick={handleDiscard}
|
||||
aria-label="Discard recording"
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<span className={styles.micWaves} aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
<Tooltip title="Stop and send">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="md"
|
||||
icon
|
||||
color="secondary"
|
||||
onClick={handleStopAndSend}
|
||||
aria-label="Stop and send"
|
||||
>
|
||||
<Square size={9} fill="currentColor" strokeWidth={0} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<Tooltip title="Voice input">
|
||||
<Button
|
||||
disabledTooltip={disabledTooltip}
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
size="md"
|
||||
icon
|
||||
onClick={(): void => startVoiceInput(VoiceInputSource.Button)}
|
||||
disabled={disabled}
|
||||
aria-label="Start voice input"
|
||||
>
|
||||
<Mic size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
{isStreaming && onCancel ? (
|
||||
<Tooltip title="Stop generating">
|
||||
<Button
|
||||
variant="solid"
|
||||
size="md"
|
||||
size="sm"
|
||||
icon
|
||||
color="danger"
|
||||
onClick={onCancel}
|
||||
aria-label="Stop generating"
|
||||
>
|
||||
<Square size={10} fill="currentColor" strokeWidth={0} />
|
||||
<Square size={14} fill="currentColor" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
@@ -1247,14 +979,14 @@ export default function ChatInput({
|
||||
<Button
|
||||
disabledTooltip={disabled ? disabledTooltip : 'Type a message first'}
|
||||
variant="solid"
|
||||
size="md"
|
||||
size="sm"
|
||||
icon
|
||||
color="primary"
|
||||
onClick={isListening ? handleStopAndSend : handleSend}
|
||||
onClick={handleSend}
|
||||
disabled={disabled || (!text.trim() && pendingFiles.length === 0)}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Send size={14} />
|
||||
<ArrowUp size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
.caret {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 2px;
|
||||
// Overhangs the glyph box by 1px each way, like the native caret.
|
||||
margin: -1px 0 0 -1px;
|
||||
padding: 1px 0;
|
||||
border-radius: 1px;
|
||||
pointer-events: none;
|
||||
background: var(--accent-primary);
|
||||
transition: transform 0.05s linear;
|
||||
animation: caretBlink 1s step-end infinite;
|
||||
|
||||
// Solid while keys are going, then blinks again.
|
||||
&.isTyping {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.trail {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
opacity: 0.4;
|
||||
background: var(--accent-primary);
|
||||
animation: trailFade 0.3s ease-out 0.4s forwards;
|
||||
|
||||
&.isFaint {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes caretBlink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes trailFade {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.caret,
|
||||
.trail {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { RefObject } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { useComposerCaret } from './useComposerCaret';
|
||||
|
||||
import styles from './ComposerCaret.module.scss';
|
||||
|
||||
interface ComposerCaretProps {
|
||||
textareaRef: RefObject<HTMLTextAreaElement>;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export default function ComposerCaret({
|
||||
textareaRef,
|
||||
value,
|
||||
}: ComposerCaretProps): JSX.Element {
|
||||
const { caret, isTyping, trails, removeTrail } = useComposerCaret(
|
||||
textareaRef,
|
||||
value,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{trails.map((trail) => (
|
||||
<span
|
||||
key={trail.id}
|
||||
className={cx(styles.trail, { [styles.isFaint]: trail.faint })}
|
||||
style={{
|
||||
left: trail.left,
|
||||
top: trail.top,
|
||||
width: trail.width,
|
||||
height: trail.height,
|
||||
}}
|
||||
onAnimationEnd={(): void => removeTrail(trail.id)}
|
||||
aria-hidden
|
||||
/>
|
||||
))}
|
||||
{caret && (
|
||||
<span
|
||||
className={cx(styles.caret, { [styles.isTyping]: isTyping })}
|
||||
style={{
|
||||
transform: `translate(${caret.left}px, ${caret.top}px)`,
|
||||
height: caret.height,
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { CaretBox } from './types';
|
||||
|
||||
// Everything that decides where a line wraps.
|
||||
const MIRRORED_STYLES = [
|
||||
'border-top-width',
|
||||
'border-right-width',
|
||||
'border-bottom-width',
|
||||
'border-left-width',
|
||||
'border-style',
|
||||
'padding-top',
|
||||
'padding-right',
|
||||
'padding-bottom',
|
||||
'padding-left',
|
||||
'font-family',
|
||||
'font-size',
|
||||
'font-style',
|
||||
'font-variant',
|
||||
'font-weight',
|
||||
'font-stretch',
|
||||
'font-feature-settings',
|
||||
'font-variation-settings',
|
||||
'font-kerning',
|
||||
'line-height',
|
||||
'letter-spacing',
|
||||
'word-spacing',
|
||||
'text-align',
|
||||
'text-indent',
|
||||
'text-transform',
|
||||
'tab-size',
|
||||
'white-space',
|
||||
'word-break',
|
||||
'overflow-wrap',
|
||||
];
|
||||
|
||||
// Caret box in the textarea's border box, measured on an off-screen copy of its text.
|
||||
export function getCaretCoordinates(
|
||||
textarea: HTMLTextAreaElement,
|
||||
position: number,
|
||||
): CaretBox {
|
||||
const computed = getComputedStyle(textarea);
|
||||
const borderTop = parseFloat(computed.borderTopWidth) || 0;
|
||||
const borderLeft = parseFloat(computed.borderLeftWidth) || 0;
|
||||
const borderRight = parseFloat(computed.borderRightWidth) || 0;
|
||||
|
||||
const mirror = document.createElement('div');
|
||||
MIRRORED_STYLES.forEach((name) => {
|
||||
mirror.style.setProperty(name, computed.getPropertyValue(name));
|
||||
});
|
||||
Object.assign(mirror.style, {
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '-9999px',
|
||||
visibility: 'hidden',
|
||||
overflow: 'hidden',
|
||||
boxSizing: 'border-box',
|
||||
width: `${textarea.clientWidth + borderLeft + borderRight}px`,
|
||||
});
|
||||
|
||||
mirror.textContent = textarea.value.slice(0, position);
|
||||
const marker = document.createElement('span');
|
||||
marker.textContent = textarea.value.slice(position) || '.';
|
||||
mirror.appendChild(marker);
|
||||
document.body.appendChild(mirror);
|
||||
|
||||
const box = {
|
||||
left: marker.offsetLeft + borderLeft,
|
||||
top: marker.offsetTop + borderTop,
|
||||
height:
|
||||
marker.getClientRects()[0]?.height || parseFloat(computed.lineHeight) || 0,
|
||||
};
|
||||
mirror.remove();
|
||||
return box;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface CaretBox {
|
||||
left: number;
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface CaretTrail extends CaretBox {
|
||||
id: number;
|
||||
width: number;
|
||||
faint: boolean;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { getCaretCoordinates } from './getCaretCoordinates';
|
||||
import type { CaretBox, CaretTrail } from './types';
|
||||
|
||||
interface UseComposerCaretResult {
|
||||
caret: CaretBox | null;
|
||||
isTyping: boolean;
|
||||
trails: CaretTrail[];
|
||||
removeTrail: (id: number) => void;
|
||||
}
|
||||
|
||||
const TYPING_IDLE_MS = 800;
|
||||
const MAX_TRAILS = 4;
|
||||
const ARROW_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']);
|
||||
const EDIT_KEYS = new Set(['Backspace', 'Delete']);
|
||||
|
||||
export function useComposerCaret(
|
||||
textareaRef: RefObject<HTMLTextAreaElement>,
|
||||
value: string,
|
||||
): UseComposerCaretResult {
|
||||
const [caret, setCaret] = useState<CaretBox | null>(null);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [trails, setTrails] = useState<CaretTrail[]>([]);
|
||||
const lastCaretRef = useRef<CaretBox | null>(null);
|
||||
const trailIdRef = useRef(0);
|
||||
const typingTimerRef = useRef<number>();
|
||||
|
||||
const measure = useCallback((): CaretBox | null => {
|
||||
const el = textareaRef.current;
|
||||
if (
|
||||
!el ||
|
||||
document.activeElement !== el ||
|
||||
el.selectionStart !== el.selectionEnd
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const box = getCaretCoordinates(el, el.selectionEnd);
|
||||
const top = el.offsetTop + box.top - el.scrollTop;
|
||||
const isInView =
|
||||
top >= el.offsetTop && top + box.height <= el.offsetTop + el.offsetHeight;
|
||||
if (!isInView) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
left: el.offsetLeft + box.left - el.scrollLeft,
|
||||
top,
|
||||
height: box.height,
|
||||
};
|
||||
}, [textareaRef]);
|
||||
|
||||
const update = useCallback((): void => {
|
||||
const next = measure();
|
||||
lastCaretRef.current = next;
|
||||
setCaret(next);
|
||||
}, [measure]);
|
||||
|
||||
// Leaves a fading streak over a sideways jump on the same line.
|
||||
const addTrail = useCallback((from: CaretBox, to: CaretBox): void => {
|
||||
if (Math.abs(to.top - from.top) >= 5 || Math.abs(to.left - from.left) <= 2) {
|
||||
return;
|
||||
}
|
||||
trailIdRef.current += 1;
|
||||
const id = trailIdRef.current;
|
||||
setTrails((prev) =>
|
||||
[
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
left: Math.min(from.left, to.left),
|
||||
top: to.top,
|
||||
width: Math.abs(to.left - from.left),
|
||||
height: to.height,
|
||||
faint: prev.length === 0,
|
||||
},
|
||||
].slice(-MAX_TRAILS),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const removeTrail = useCallback((id: number): void => {
|
||||
setTrails((prev) => prev.filter((trail) => trail.id !== id));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) {
|
||||
return undefined;
|
||||
}
|
||||
let frame = 0;
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
const isArrow = ARROW_KEYS.has(event.key);
|
||||
if (!isArrow && !EDIT_KEYS.has(event.key) && event.key.length !== 1) {
|
||||
return;
|
||||
}
|
||||
setIsTyping(true);
|
||||
window.clearTimeout(typingTimerRef.current);
|
||||
typingTimerRef.current = window.setTimeout(
|
||||
() => setIsTyping(false),
|
||||
TYPING_IDLE_MS,
|
||||
);
|
||||
if (isArrow) {
|
||||
const from = lastCaretRef.current;
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
const to = measure();
|
||||
if (from && to) {
|
||||
addTrail(from, to);
|
||||
}
|
||||
lastCaretRef.current = to;
|
||||
setCaret(to);
|
||||
});
|
||||
}
|
||||
};
|
||||
const onSelectionChange = (): void => {
|
||||
if (document.activeElement === el) {
|
||||
update();
|
||||
}
|
||||
};
|
||||
const hide = (): void => {
|
||||
lastCaretRef.current = null;
|
||||
setCaret(null);
|
||||
};
|
||||
const observer = new ResizeObserver(() => update());
|
||||
|
||||
el.addEventListener('keydown', onKeyDown);
|
||||
el.addEventListener('focus', update);
|
||||
el.addEventListener('blur', hide);
|
||||
el.addEventListener('scroll', update);
|
||||
document.addEventListener('selectionchange', onSelectionChange);
|
||||
observer.observe(el);
|
||||
update();
|
||||
|
||||
return (): void => {
|
||||
el.removeEventListener('keydown', onKeyDown);
|
||||
el.removeEventListener('focus', update);
|
||||
el.removeEventListener('blur', hide);
|
||||
el.removeEventListener('scroll', update);
|
||||
document.removeEventListener('selectionchange', onSelectionChange);
|
||||
observer.disconnect();
|
||||
cancelAnimationFrame(frame);
|
||||
window.clearTimeout(typingTimerRef.current);
|
||||
};
|
||||
}, [textareaRef, measure, update, addTrail]);
|
||||
|
||||
// Text set from code (prefill, @-mentions, send) moves the caret without a key.
|
||||
useLayoutEffect(() => {
|
||||
update();
|
||||
}, [value, update]);
|
||||
|
||||
return { caret, isTyping, trails, removeTrail };
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Offsets are from the input's padding box; its top edge, border or separator, sits at -1px.
|
||||
|
||||
.edgeSweep {
|
||||
position: absolute;
|
||||
top: -11px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 20px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent 11px,
|
||||
var(--l1-background) 60px,
|
||||
var(--l1-background) calc(100% - 60px),
|
||||
transparent calc(100% - 11px)
|
||||
);
|
||||
|
||||
&.isVisible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.line {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
left: 0;
|
||||
width: 40%;
|
||||
height: 2px;
|
||||
animation: edgeSweep 2s ease-in-out infinite alternate;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
var(--accent-primary) 20%,
|
||||
var(--accent-sakura) 40%,
|
||||
var(--accent-primary) 60%,
|
||||
var(--accent-sakura) 80%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
top: 0;
|
||||
height: 2px;
|
||||
filter: blur(4px);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes edgeSweep {
|
||||
from {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(200%);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './EdgeSweep.module.scss';
|
||||
|
||||
interface EdgeSweepProps {
|
||||
/** When the current stream began; null while idle. */
|
||||
startedAt: number | null;
|
||||
}
|
||||
|
||||
interface SweepRun {
|
||||
startedAt: number;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
/** Gradient sweeping the input's top edge while Noz replies. */
|
||||
export default function EdgeSweep({ startedAt }: EdgeSweepProps): JSX.Element {
|
||||
// Outlives the stream so the line keeps moving while it fades out.
|
||||
const [run, setRun] = useState<SweepRun | null>(() =>
|
||||
startedAt === null ? null : { startedAt, elapsedMs: Date.now() - startedAt },
|
||||
);
|
||||
// A new stream restarts from the left; a remount mid-stream picks up where it was.
|
||||
if (startedAt !== null && startedAt !== run?.startedAt) {
|
||||
setRun({ startedAt, elapsedMs: Date.now() - startedAt });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.edgeSweep, { [styles.isVisible]: startedAt !== null })}
|
||||
onTransitionEnd={(): void => {
|
||||
if (startedAt === null) {
|
||||
setRun(null);
|
||||
}
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{run && (
|
||||
<div
|
||||
key={run.startedAt}
|
||||
className={styles.line}
|
||||
style={{ animationDelay: `${-run.elapsedMs}ms` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Offsets are from the input's padding box; its top edge, border or separator, sits at -1px.
|
||||
|
||||
.body {
|
||||
position: absolute;
|
||||
top: -29px;
|
||||
right: 6px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
pointer-events: none;
|
||||
// Hides what is below the top edge, as if Noz stood behind the input.
|
||||
clip-path: inset(-50% -50% 12px -50%);
|
||||
}
|
||||
|
||||
.bodySvg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
animation: bodyPeek 0.7s cubic-bezier(0.34, 1.45, 0.5, 1) 0.95s both;
|
||||
}
|
||||
|
||||
.head {
|
||||
transform-origin: 12px 16px;
|
||||
animation: headBob 0.5s ease-in-out 1.05s 2;
|
||||
}
|
||||
|
||||
.pupil {
|
||||
transition: transform 0.13s ease-out;
|
||||
}
|
||||
|
||||
.hands {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: 9px;
|
||||
width: 34px;
|
||||
height: 7px;
|
||||
pointer-events: none;
|
||||
// Behind the top edge on the way up, in front of it on the way down.
|
||||
animation: handsClip 0.7s 0.6s both;
|
||||
}
|
||||
|
||||
.handsSvg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
animation: handsPeek 0.7s cubic-bezier(0.22, 1, 0.36, 1) 0.6s both;
|
||||
}
|
||||
|
||||
@keyframes bodyPeek {
|
||||
from {
|
||||
transform: translateY(34px);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes headBob {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-1px) rotate(-6deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes handsPeek {
|
||||
0% {
|
||||
transform: translateY(16px);
|
||||
opacity: 0;
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
50%,
|
||||
51% {
|
||||
transform: translateY(-7px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(2px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes handsClip {
|
||||
0%,
|
||||
50% {
|
||||
clip-path: inset(-20px -20px 3px -20px);
|
||||
}
|
||||
51%,
|
||||
100% {
|
||||
clip-path: inset(-20px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bodySvg,
|
||||
.head,
|
||||
.hands,
|
||||
.handsSvg {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.handsSvg {
|
||||
transform: translateY(2px);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { useNozPupilTracking } from './useNozPupilTracking';
|
||||
|
||||
import styles from './NozPeek.module.scss';
|
||||
|
||||
/** Noz climbs over the input's top edge, hands first, then watches the cursor. */
|
||||
export default function NozPeek(): JSX.Element {
|
||||
const eyeRef = useRef<SVGCircleElement>(null);
|
||||
const pupilRef = useRef<SVGGElement>(null);
|
||||
useNozPupilTracking(eyeRef, pupilRef);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.body} aria-hidden>
|
||||
<svg
|
||||
className={styles.bodySvg}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
x="4.35938"
|
||||
y="8.49908"
|
||||
width="15.4569"
|
||||
height="11.978"
|
||||
rx="1.76147"
|
||||
fill="#E5484D"
|
||||
/>
|
||||
<g className={styles.head}>
|
||||
<circle
|
||||
ref={eyeRef}
|
||||
cx="12.0217"
|
||||
cy="14.4881"
|
||||
r="3.87523"
|
||||
fill="#F5F5F5"
|
||||
/>
|
||||
<g ref={pupilRef} className={styles.pupil}>
|
||||
<path
|
||||
d="M12.0237 12.8024C12.0237 13.7328 11.2673 14.4892 10.337 14.4892C10.0339 14.4892 9.74926 14.4101 9.50152 14.2678C9.47517 14.5551 9.49888 14.8502 9.57795 15.1428C9.93901 16.4921 11.3279 17.2933 12.6773 16.9323C14.0267 16.5712 14.8279 15.1823 14.4668 13.8329C14.1453 12.6285 13.0041 11.8616 11.8023 11.967C11.942 12.2121 12.0237 12.4967 12.0237 12.8024Z"
|
||||
fill="#0A0C10"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
d="M8.33833 7.94578L9.83358 4.31319C10.1302 3.59261 10.6676 2.99939 11.355 2.63299L13.9181 1.26684C14.1327 1.15169 14.3804 1.34885 14.3194 1.58439L13.6703 4.06892C13.6511 4.14046 13.6424 4.21374 13.6424 4.28876C13.6424 4.39868 13.6633 4.5086 13.7052 4.61154L15.0382 7.94578H11.4248L11.6307 7.32813L12.3356 7.09259C12.449 7.05421 12.5257 6.94778 12.5257 6.82739C12.5257 6.707 12.449 6.60057 12.3356 6.56218L11.6307 6.32664L11.3951 5.62176C11.3568 5.51009 11.2503 5.43333 11.1299 5.43333C11.0096 5.43333 10.9031 5.5101 10.8647 5.6235L10.6292 6.32839L9.92431 6.56393C9.8109 6.60231 9.73413 6.70874 9.73413 6.82913C9.73413 6.94952 9.8109 7.05595 9.92431 7.09434L10.6292 7.32988L10.8351 7.94752H8.33833V7.94578ZM12.1 3.43558C12.0808 3.378 12.0285 3.33962 11.9674 3.33962C11.9064 3.33962 11.854 3.378 11.8348 3.43558L11.7179 3.78802L11.3655 3.90492C11.3079 3.92411 11.2695 3.97645 11.2695 4.03752C11.2695 4.09859 11.3079 4.15093 11.3655 4.17012L11.7179 4.28702L11.8348 4.63946C11.854 4.69704 11.9064 4.73542 11.9674 4.73542C12.0285 4.73542 12.0808 4.69704 12.1 4.63946L12.2169 4.28702L12.5694 4.17012C12.6269 4.15093 12.6653 4.09859 12.6653 4.03752C12.6653 3.97645 12.6269 3.92411 12.5694 3.90492L12.2169 3.78802L12.1 3.43558ZM7.78 7.91088H15.5965C15.9053 7.91088 16.1548 8.16038 16.1548 8.4692C16.1548 8.77803 15.9053 9.02753 15.5965 9.02753H7.78C7.47118 9.02753 7.22168 8.77803 7.22168 8.4692C7.22168 8.16038 7.47118 7.91088 7.78 7.91088Z"
|
||||
fill="#4E74F8"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div className={styles.hands} aria-hidden>
|
||||
<svg
|
||||
className={styles.handsSvg}
|
||||
viewBox="0 0 34 7"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect width="3.52294" height="6.16514" rx="1.46789" fill="#E5484D" />
|
||||
<rect
|
||||
x="30.4775"
|
||||
width="3.52294"
|
||||
height="6.16514"
|
||||
rx="1.46789"
|
||||
fill="#E5484D"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { RefObject, useEffect } from 'react';
|
||||
|
||||
// Noz has settled on the edge by now.
|
||||
const SETTLE_MS = 1900;
|
||||
// Furthest the pupil moves, in SVG units.
|
||||
const MAX_OFFSET = 1.25;
|
||||
// Cursor distance, in px, at which the pupil reaches MAX_OFFSET.
|
||||
const FULL_REACH_PX = 160;
|
||||
|
||||
export function useNozPupilTracking(
|
||||
eyeRef: RefObject<SVGCircleElement>,
|
||||
pupilRef: RefObject<SVGGElement>,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const prefersReducedMotion = window.matchMedia(
|
||||
'(prefers-reduced-motion: reduce)',
|
||||
).matches;
|
||||
let isSettled = false;
|
||||
const timer = window.setTimeout(
|
||||
() => {
|
||||
isSettled = true;
|
||||
},
|
||||
prefersReducedMotion ? 0 : SETTLE_MS,
|
||||
);
|
||||
|
||||
const onMouseMove = (event: MouseEvent): void => {
|
||||
const eye = eyeRef.current;
|
||||
const pupil = pupilRef.current;
|
||||
if (!isSettled || !eye || !pupil) {
|
||||
return;
|
||||
}
|
||||
const rect = eye.getBoundingClientRect();
|
||||
const dx = event.clientX - (rect.left + rect.width / 2);
|
||||
const dy = event.clientY - (rect.top + rect.height / 2);
|
||||
const distance = Math.hypot(dx, dy) || 1;
|
||||
const reach = Math.min(1, distance / FULL_REACH_PX) * MAX_OFFSET;
|
||||
pupil.style.transform = `translate(${((dx / distance) * reach).toFixed(3)}px, ${((dy / distance) * reach).toFixed(3)}px)`;
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
return (): void => {
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
};
|
||||
}, [eyeRef, pupilRef]);
|
||||
}
|
||||
@@ -23,24 +23,6 @@ jest.mock('hooks/useQueryService', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../hooks/useSpeechRecognition', () => ({
|
||||
useSpeechRecognition: (): unknown => ({
|
||||
isListening: false,
|
||||
isSupported: false,
|
||||
permission: 'prompt',
|
||||
start: jest.fn(),
|
||||
discard: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../hooks/useAIAssistantAnalyticsContext', () => ({
|
||||
useAIAssistantAnalyticsContext: (): unknown => ({
|
||||
threadId: undefined,
|
||||
page: '/',
|
||||
mode: 'sidepane',
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import ChatInput from '../ChatInput';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import ChatInput from '../ChatInput';
|
||||
|
||||
function getComposer(): HTMLTextAreaElement {
|
||||
return screen.getByPlaceholderText(/Ask anything/i) as HTMLTextAreaElement;
|
||||
return screen.getByPlaceholderText(/Ask Noz/i) as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
describe('ChatInput — @ mention trigger', () => {
|
||||
|
||||
@@ -29,26 +29,6 @@ jest.mock('hooks/useQueryService', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Irrelevant to the prefill flow and otherwise require browser APIs / extra
|
||||
// context providers, so stub them out.
|
||||
jest.mock('../../../hooks/useSpeechRecognition', () => ({
|
||||
useSpeechRecognition: (): unknown => ({
|
||||
isListening: false,
|
||||
isSupported: false,
|
||||
permission: 'prompt',
|
||||
start: jest.fn(),
|
||||
discard: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../hooks/useAIAssistantAnalyticsContext', () => ({
|
||||
useAIAssistantAnalyticsContext: (): unknown => ({
|
||||
threadId: undefined,
|
||||
page: '/',
|
||||
mode: 'sidepane',
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
// eslint-disable-next-line import/first
|
||||
import ChatInput from '../ChatInput';
|
||||
@@ -58,7 +38,7 @@ function renderChatInput(): void {
|
||||
}
|
||||
|
||||
function getComposer(): HTMLTextAreaElement {
|
||||
return screen.getByPlaceholderText(/Ask anything/i) as HTMLTextAreaElement;
|
||||
return screen.getByPlaceholderText(/Ask Noz/i) as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
describe('ChatInput — empty-state CTA prefill flow', () => {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
.dotGrid {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// The Snake score line, faint in the grid's bottom-left corner.
|
||||
.label {
|
||||
position: absolute;
|
||||
left: var(--spacing-6);
|
||||
bottom: var(--spacing-1);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: var(--periscope-font-size-small);
|
||||
letter-spacing: 0.34em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
color: var(--l3-foreground);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useRef } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { useDotGrid } from './useDotGrid';
|
||||
|
||||
import styles from './DotGrid.module.scss';
|
||||
|
||||
interface DotGridProps {
|
||||
/** Sizes and places the grid; it fills whatever box it gets. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Aurora dot grid that drifts on its own and lights up under the cursor. */
|
||||
export default function DotGrid({ className }: DotGridProps): JSX.Element {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const label = useDotGrid(canvasRef);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.dotGrid, className)} aria-hidden>
|
||||
<canvas ref={canvasRef} className={styles.canvas} />
|
||||
{label && <span className={styles.label}>{label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Tunables and the drifting aurora field from the design's dot-grid prototype.
|
||||
|
||||
export const DOT_SPACING = 12;
|
||||
export const DOT_RADIUS = 1.15;
|
||||
|
||||
export const TRAIL_RADIUS = 72;
|
||||
export const TRAIL_LIFT = 0.24;
|
||||
export const TRAIL_MAX_POINTS = 18;
|
||||
export const TRAIL_LIFE_MS = 1000;
|
||||
export const TRAIL_MIN_STEP = 7;
|
||||
|
||||
// Time the field rests at when motion is reduced.
|
||||
export const STILL_TIME_MS = 12000;
|
||||
|
||||
type Rgb = [number, number, number];
|
||||
|
||||
// Green, teal, blue, violet and pink, in the order the field drifts through them.
|
||||
const AURORA: Rgb[] = [
|
||||
[64, 224, 150],
|
||||
[48, 200, 210],
|
||||
[90, 130, 245],
|
||||
[170, 110, 245],
|
||||
[235, 110, 200],
|
||||
];
|
||||
|
||||
/** Brightness of the field at a point, between -1 and 1. */
|
||||
export function auroraBrightness(x: number, y: number, t: number): number {
|
||||
return (
|
||||
(Math.sin(x * 0.009 + t * 0.0006) +
|
||||
Math.sin(y * 0.015 - t * 0.00085 + x * 0.002) +
|
||||
Math.sin((x + y) * 0.006 + t * 0.0011) +
|
||||
Math.sin((x - y) * 0.0048 - t * 0.0007)) /
|
||||
4
|
||||
);
|
||||
}
|
||||
|
||||
/** Colour of the field at a point, blended along the aurora palette. */
|
||||
export function auroraColor(x: number, y: number, t: number): Rgb {
|
||||
const position =
|
||||
((Math.sin(x * 0.0035 + t * 0.00018) +
|
||||
Math.sin(y * 0.005 - t * 0.00026) +
|
||||
Math.sin((x + y) * 0.0028 + t * 0.0002)) /
|
||||
3 +
|
||||
1) /
|
||||
2;
|
||||
const scaled = position * (AURORA.length - 1);
|
||||
const from = AURORA[Math.floor(scaled)];
|
||||
const to = AURORA[Math.min(Math.floor(scaled) + 1, AURORA.length - 1)];
|
||||
const mix = scaled - Math.floor(scaled);
|
||||
return [
|
||||
from[0] + (to[0] - from[0]) * mix,
|
||||
from[1] + (to[1] - from[1]) * mix,
|
||||
from[2] + (to[2] - from[2]) * mix,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Painting for the dot grid: the aurora field with the cursor's glow, and plain dots.
|
||||
|
||||
import {
|
||||
auroraBrightness,
|
||||
auroraColor,
|
||||
DOT_RADIUS,
|
||||
TRAIL_LIFE_MS,
|
||||
TRAIL_LIFT,
|
||||
TRAIL_RADIUS,
|
||||
} from './dotGridField';
|
||||
|
||||
export interface TrailPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
born: number;
|
||||
}
|
||||
|
||||
interface AuroraFrame {
|
||||
/** Field time; frozen when motion is reduced. */
|
||||
t: number;
|
||||
now: number;
|
||||
height: number;
|
||||
trail: TrailPoint[];
|
||||
isGameOn: boolean;
|
||||
/** Dots below this stay dark. */
|
||||
cutoffY: number;
|
||||
}
|
||||
|
||||
/** The faint light that washes up from the bottom while the game is on. */
|
||||
export function gameFloor(y: number, height: number): number {
|
||||
return 0.07 + 0.16 * (y / height);
|
||||
}
|
||||
|
||||
export function drawDot(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
radius: number,
|
||||
fill: string,
|
||||
): void {
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = fill;
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
/** Paints the aurora dots, brightest at the top and lit along the cursor's trail. */
|
||||
export function drawAuroraDots(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
dots: { x: number; y: number }[],
|
||||
{ t, now, height, trail, isGameOn, cutoffY }: AuroraFrame,
|
||||
): void {
|
||||
// Overlapping glows add up, as light does.
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
dots.forEach(({ x, y }) => {
|
||||
if (y > cutoffY) {
|
||||
return;
|
||||
}
|
||||
// Brightest at the top, fading toward the content below.
|
||||
const topWeight = 1 - 0.82 * Math.min(1, y / height);
|
||||
const lum = Math.max(0, auroraBrightness(x, y, t)) ** 1.6;
|
||||
let intensity = (0.06 + lum * 0.4) * topWeight * (isGameOn ? 0.5 : 1);
|
||||
if (isGameOn) {
|
||||
intensity += gameFloor(y, height);
|
||||
}
|
||||
|
||||
let glow = 0;
|
||||
trail.forEach((point) => {
|
||||
const distance2 = (x - point.x) ** 2 + (y - point.y) ** 2;
|
||||
if (distance2 < TRAIL_RADIUS ** 2) {
|
||||
const life = 1 - (now - point.born) / TRAIL_LIFE_MS;
|
||||
glow += (1 - distance2 / TRAIL_RADIUS ** 2) ** 3 * life;
|
||||
}
|
||||
});
|
||||
intensity += Math.min(glow, 1.3) * TRAIL_LIFT * topWeight;
|
||||
|
||||
if (intensity <= 0.02) {
|
||||
return;
|
||||
}
|
||||
const [red, green, blue] = auroraColor(x, y, t);
|
||||
const white = Math.min(1, Math.max(0, intensity - 0.9)) * 0.9;
|
||||
const rgb = `${Math.round(red + (255 - red) * white)},${Math.round(
|
||||
green + (255 - green) * white,
|
||||
)},${Math.round(blue + (255 - blue) * white)}`;
|
||||
const alpha = Math.min(1, intensity);
|
||||
const radius = DOT_RADIUS + Math.min(intensity, 1.2) * 0.7;
|
||||
|
||||
if (intensity > 0.6) {
|
||||
drawDot(ctx, x, y, radius * 2.6, `rgba(${rgb},${alpha * 0.06})`);
|
||||
}
|
||||
drawDot(ctx, x, y, radius, `rgba(${rgb},${alpha})`);
|
||||
});
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Snake on the dot grid, the easter egg the Konami code unlocks.
|
||||
|
||||
interface Cell {
|
||||
c: number;
|
||||
r: number;
|
||||
}
|
||||
|
||||
export interface SnakeGame {
|
||||
body: Cell[];
|
||||
dir: Cell;
|
||||
nextDir: Cell;
|
||||
food: Cell;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export const SNAKE_STEP_MS = 110;
|
||||
|
||||
export const KONAMI_CODE = [
|
||||
'arrowup',
|
||||
'arrowup',
|
||||
'arrowdown',
|
||||
'arrowdown',
|
||||
'arrowleft',
|
||||
'arrowright',
|
||||
'arrowleft',
|
||||
'arrowright',
|
||||
'b',
|
||||
'a',
|
||||
];
|
||||
|
||||
export const SNAKE_DIRECTIONS: Record<string, Cell> = {
|
||||
arrowup: { c: 0, r: -1 },
|
||||
w: { c: 0, r: -1 },
|
||||
arrowdown: { c: 0, r: 1 },
|
||||
s: { c: 0, r: 1 },
|
||||
arrowleft: { c: -1, r: 0 },
|
||||
a: { c: -1, r: 0 },
|
||||
arrowright: { c: 1, r: 0 },
|
||||
d: { c: 1, r: 0 },
|
||||
};
|
||||
|
||||
const isOn = (cells: Cell[], cell: Cell): boolean =>
|
||||
cells.some(({ c, r }) => c === cell.c && r === cell.r);
|
||||
|
||||
/** A free cell for the food, or null when the snake fills the grid. */
|
||||
function placeFood(body: Cell[], cols: number, rows: number): Cell | null {
|
||||
const free: Cell[] = [];
|
||||
for (let r = 0; r < rows; r += 1) {
|
||||
for (let c = 0; c < cols; c += 1) {
|
||||
if (!isOn(body, { c, r })) {
|
||||
free.push({ c, r });
|
||||
}
|
||||
}
|
||||
}
|
||||
return free.length ? free[Math.floor(Math.random() * free.length)] : null;
|
||||
}
|
||||
|
||||
/** A four-cell snake in the middle heading right, or null when the grid is too small. */
|
||||
export function startSnake(cols: number, rows: number): SnakeGame | null {
|
||||
if (cols < 8 || rows < 6) {
|
||||
return null;
|
||||
}
|
||||
const c = Math.floor(cols / 2);
|
||||
const r = Math.floor(rows / 2);
|
||||
const body = [0, 1, 2, 3].map((i) => ({ c: c - i, r }));
|
||||
const food = placeFood(body, cols, rows);
|
||||
if (!food) {
|
||||
return null;
|
||||
}
|
||||
const right = { c: 1, r: 0 };
|
||||
return { body, dir: right, nextDir: right, food, score: 0 };
|
||||
}
|
||||
|
||||
/** Moves the snake one cell; null when it hits a wall, itself, or fills the grid. */
|
||||
export function stepSnake(
|
||||
game: SnakeGame,
|
||||
cols: number,
|
||||
rows: number,
|
||||
): SnakeGame | null {
|
||||
const dir = game.nextDir;
|
||||
const head = { c: game.body[0].c + dir.c, r: game.body[0].r + dir.r };
|
||||
if (
|
||||
head.c < 0 ||
|
||||
head.c >= cols ||
|
||||
head.r < 0 ||
|
||||
head.r >= rows ||
|
||||
isOn(game.body, head)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const ate = head.c === game.food.c && head.r === game.food.r;
|
||||
const body = [head, ...(ate ? game.body : game.body.slice(0, -1))];
|
||||
const food = ate ? placeFood(body, cols, rows) : game.food;
|
||||
if (!food) {
|
||||
return null;
|
||||
}
|
||||
return { ...game, body, dir, food, score: game.score + (ate ? 1 : 0) };
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { RefObject, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
DOT_RADIUS,
|
||||
DOT_SPACING,
|
||||
STILL_TIME_MS,
|
||||
TRAIL_LIFE_MS,
|
||||
TRAIL_MAX_POINTS,
|
||||
TRAIL_MIN_STEP,
|
||||
TRAIL_RADIUS,
|
||||
} from './dotGridField';
|
||||
import { drawAuroraDots, drawDot, gameFloor, TrailPoint } from './drawDots';
|
||||
import {
|
||||
KONAMI_CODE,
|
||||
SNAKE_DIRECTIONS,
|
||||
SNAKE_STEP_MS,
|
||||
SnakeGame,
|
||||
startSnake,
|
||||
stepSnake,
|
||||
} from './snakeGame';
|
||||
|
||||
const GAME_OVER_LABEL_MS = 4000;
|
||||
const REPLAY_LABEL = '↑ to replay';
|
||||
|
||||
// Unlocked once per page load; after that a click on the dots starts a game.
|
||||
let isSnakeUnlocked = false;
|
||||
// Room left under the snake's lowest row for the score line, so the two never touch.
|
||||
const SCORE_LINE_CLEARANCE = 30;
|
||||
|
||||
/** Draws the aurora dot grid, lit under the cursor; the Konami code turns it into Snake. Returns the score line. */
|
||||
export function useDotGrid(
|
||||
canvasRef: RefObject<HTMLCanvasElement>,
|
||||
): string | null {
|
||||
const [label, setLabel] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
if (!canvas || !ctx) {
|
||||
return undefined;
|
||||
}
|
||||
const isStill = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let cols = 0;
|
||||
let rows = 0;
|
||||
let gameRows = 0;
|
||||
let left = 0;
|
||||
let dots: { x: number; y: number }[] = [];
|
||||
const trail: TrailPoint[] = [];
|
||||
let frame = 0;
|
||||
let game: SnakeGame | null = null;
|
||||
let lastStep = 0;
|
||||
// With reduced motion the field stands still, so frames repaint only after a change.
|
||||
let needsDraw = true;
|
||||
let isScoreLineShown = false;
|
||||
const showScoreLine = (text: string | null): void => {
|
||||
isScoreLineShown = text !== null;
|
||||
needsDraw = true;
|
||||
setLabel(text);
|
||||
};
|
||||
const recentKeys: string[] = [];
|
||||
let labelTimer = 0;
|
||||
// An arrow key replays while the game over line is up.
|
||||
let canReplay = false;
|
||||
const showPlayCursor = (): void => {
|
||||
canvas.style.cursor = isSnakeUnlocked && !game ? 'pointer' : '';
|
||||
};
|
||||
|
||||
const cellCenter = (c: number, r: number): { x: number; y: number } => ({
|
||||
x: left + c * DOT_SPACING,
|
||||
y: DOT_SPACING + r * DOT_SPACING,
|
||||
});
|
||||
|
||||
const play = (): void => {
|
||||
game = startSnake(cols, gameRows);
|
||||
lastStep = performance.now();
|
||||
canReplay = false;
|
||||
window.clearTimeout(labelTimer);
|
||||
showScoreLine(game ? 'Snake · score 0 · ↑ ↓ ← →' : null);
|
||||
showPlayCursor();
|
||||
};
|
||||
const endGame = (score: number): void => {
|
||||
game = null;
|
||||
canReplay = true;
|
||||
showScoreLine(`Game over · score ${score} · ${REPLAY_LABEL}`);
|
||||
showPlayCursor();
|
||||
labelTimer = window.setTimeout(() => {
|
||||
canReplay = false;
|
||||
showScoreLine(null);
|
||||
}, GAME_OVER_LABEL_MS);
|
||||
};
|
||||
|
||||
const layout = (): void => {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
width = canvas.clientWidth;
|
||||
height = canvas.clientHeight;
|
||||
canvas.width = Math.floor(width * dpr);
|
||||
canvas.height = Math.floor(height * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
cols = Math.max(0, Math.floor((width - DOT_SPACING) / DOT_SPACING));
|
||||
rows = Math.max(0, Math.floor((height - DOT_SPACING) / DOT_SPACING));
|
||||
gameRows = Math.min(
|
||||
rows,
|
||||
Math.max(0, Math.floor((height - SCORE_LINE_CLEARANCE) / DOT_SPACING)),
|
||||
);
|
||||
left = (width - cols * DOT_SPACING) / 2 + DOT_SPACING / 2;
|
||||
dots = [];
|
||||
for (let r = 0; r < rows; r += 1) {
|
||||
for (let c = 0; c < cols; c += 1) {
|
||||
dots.push(cellCenter(c, r));
|
||||
}
|
||||
}
|
||||
needsDraw = true;
|
||||
// A game carries on through a resize unless it no longer fits.
|
||||
if (
|
||||
game &&
|
||||
[...game.body, game.food].some(({ c, r }) => c >= cols || r >= gameRows)
|
||||
) {
|
||||
play();
|
||||
}
|
||||
};
|
||||
|
||||
const onPointer = (event: PointerEvent): void => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
if (
|
||||
x < -TRAIL_RADIUS ||
|
||||
y < -TRAIL_RADIUS ||
|
||||
x > width + TRAIL_RADIUS ||
|
||||
y > height + TRAIL_RADIUS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const head = trail[trail.length - 1];
|
||||
// Barely moved: keep the head lit instead of stacking points.
|
||||
if (head && Math.hypot(x - head.x, y - head.y) < TRAIL_MIN_STEP) {
|
||||
head.born = performance.now();
|
||||
return;
|
||||
}
|
||||
trail.push({ x, y, born: performance.now() });
|
||||
if (trail.length > TRAIL_MAX_POINTS) {
|
||||
trail.shift();
|
||||
}
|
||||
};
|
||||
|
||||
// Keys typed into a field outside this chat belong to that field.
|
||||
const isOtherField = (target: EventTarget | null): boolean => {
|
||||
if (
|
||||
!(target instanceof HTMLElement) ||
|
||||
!target.closest('input, textarea, select, [contenteditable="true"]')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
let chat = canvas.parentElement;
|
||||
while (chat && !chat.querySelector('textarea')) {
|
||||
chat = chat.parentElement;
|
||||
}
|
||||
return !chat?.contains(target);
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (isOtherField(event.target)) {
|
||||
return;
|
||||
}
|
||||
const key = event.key.toLowerCase();
|
||||
if (game) {
|
||||
const next = SNAKE_DIRECTIONS[key];
|
||||
if (next) {
|
||||
// Steering keys drive the snake, not the input; no turning back onto the neck.
|
||||
event.preventDefault();
|
||||
if (next.c !== -game.dir.c || next.r !== -game.dir.r) {
|
||||
game.nextDir = next;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (canReplay && key.startsWith('arrow')) {
|
||||
event.preventDefault();
|
||||
play();
|
||||
return;
|
||||
}
|
||||
recentKeys.push(key);
|
||||
if (recentKeys.length > KONAMI_CODE.length) {
|
||||
recentKeys.shift();
|
||||
}
|
||||
if (recentKeys.join() === KONAMI_CODE.join()) {
|
||||
recentKeys.length = 0;
|
||||
isSnakeUnlocked = true;
|
||||
play();
|
||||
}
|
||||
};
|
||||
|
||||
const onClick = (): void => {
|
||||
if (isSnakeUnlocked && !game) {
|
||||
play();
|
||||
}
|
||||
};
|
||||
|
||||
// The snake and its food: flat white dots the size of their neighbours.
|
||||
const stepAndDrawSnake = (now: number): void => {
|
||||
if (game && now - lastStep >= SNAKE_STEP_MS) {
|
||||
lastStep = now;
|
||||
const next = stepSnake(game, cols, gameRows);
|
||||
if (next) {
|
||||
if (next.score !== game.score) {
|
||||
showScoreLine(`Snake · score ${next.score} · ↑ ↓ ← →`);
|
||||
}
|
||||
game = next;
|
||||
} else {
|
||||
endGame(game.score);
|
||||
}
|
||||
}
|
||||
if (game) {
|
||||
const sizeAt = (y: number): number =>
|
||||
DOT_RADIUS + Math.min(gameFloor(y, height), 1.2) * 0.7;
|
||||
const food = cellCenter(game.food.c, game.food.r);
|
||||
drawDot(ctx, food.x, food.y, sizeAt(food.y), 'rgba(255,255,255,0.97)');
|
||||
game.body.forEach((cell, i) => {
|
||||
const { x, y } = cellCenter(cell.c, cell.r);
|
||||
drawDot(
|
||||
ctx,
|
||||
x,
|
||||
y,
|
||||
sizeAt(y),
|
||||
i === 0 ? 'rgba(255,255,255,1)' : 'rgba(232,238,245,0.92)',
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const draw = (now: number): void => {
|
||||
const hadTrail = trail.length > 0;
|
||||
for (let i = trail.length - 1; i >= 0; i -= 1) {
|
||||
if (now - trail[i].born >= TRAIL_LIFE_MS) {
|
||||
trail.splice(i, 1);
|
||||
}
|
||||
}
|
||||
if (isStill && !game && !hadTrail && !needsDraw) {
|
||||
frame = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
needsDraw = false;
|
||||
|
||||
const t = isStill ? STILL_TIME_MS : now;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
drawAuroraDots(ctx, dots, {
|
||||
t,
|
||||
now,
|
||||
height,
|
||||
trail,
|
||||
isGameOn: game !== null,
|
||||
// Keeps the score line's strip clear of dots.
|
||||
cutoffY: isScoreLineShown ? gameRows * DOT_SPACING : Infinity,
|
||||
});
|
||||
|
||||
stepAndDrawSnake(now);
|
||||
|
||||
frame = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(layout);
|
||||
observer.observe(canvas);
|
||||
layout();
|
||||
window.addEventListener('pointermove', onPointer);
|
||||
window.addEventListener('pointerdown', onPointer);
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
canvas.addEventListener('click', onClick);
|
||||
showPlayCursor();
|
||||
frame = requestAnimationFrame(draw);
|
||||
|
||||
return (): void => {
|
||||
cancelAnimationFrame(frame);
|
||||
observer.disconnect();
|
||||
window.clearTimeout(labelTimer);
|
||||
window.removeEventListener('pointermove', onPointer);
|
||||
window.removeEventListener('pointerdown', onPointer);
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
canvas.removeEventListener('click', onClick);
|
||||
};
|
||||
}, [canvasRef]);
|
||||
|
||||
return label;
|
||||
}
|
||||
@@ -37,6 +37,11 @@
|
||||
.assistant & {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
// The full page's column is already narrow, so replies use all of it.
|
||||
.assistant &.fullPage {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.bubble {
|
||||
@@ -50,9 +55,11 @@
|
||||
max-width: 100%;
|
||||
|
||||
.user & {
|
||||
background: var(--accent-primary);
|
||||
color: var(--primary-foreground);
|
||||
border-bottom-right-radius: var(--radius-2);
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
border-radius: 8px;
|
||||
background: var(--l3-background-60);
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.assistant & {
|
||||
|
||||
@@ -141,7 +141,10 @@ export default function MessageBubble({
|
||||
[styles.compact]: isCompact,
|
||||
},
|
||||
);
|
||||
const bodyClass = cx(styles.body, { [styles.compact]: isCompact });
|
||||
const bodyClass = cx(styles.body, {
|
||||
[styles.compact]: isCompact,
|
||||
[styles.fullPage]: variant === 'page',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={messageClass} data-testid={`ai-message-${message.id}`}>
|
||||
|
||||
@@ -49,7 +49,7 @@ export default function UserMessageActions({
|
||||
onClick={handleCopy}
|
||||
aria-label={copied ? 'Copied' : 'Copy message'}
|
||||
>
|
||||
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
@use '../../../_scrollbar' as *;
|
||||
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--spacing-6);
|
||||
overflow-y: auto;
|
||||
@include scrollbar;
|
||||
|
||||
// Matches the dot grid's share of the height, so the content stays centred.
|
||||
&::after {
|
||||
content: '';
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
// The panel sits it above the input, clear of the Noz peeking over its corner.
|
||||
&.compact {
|
||||
padding-right: var(--spacing-24);
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fills the empty space above the content, edge to edge.
|
||||
.dotGrid {
|
||||
display: block;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
margin: calc(-1 * var(--spacing-6)) calc(-1 * var(--spacing-6))
|
||||
var(--spacing-6);
|
||||
|
||||
.compact & {
|
||||
margin-right: calc(-1 * var(--spacing-24));
|
||||
}
|
||||
}
|
||||
|
||||
.emptyContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-10);
|
||||
max-width: 336px;
|
||||
// Auto margins centre it without clipping the top when it outgrows the view.
|
||||
margin: auto;
|
||||
|
||||
.compact & {
|
||||
margin: auto 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.emptyIntro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
|
||||
.emptyHeading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.emptyTitle {
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-large);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.emptySubtitle {
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.005em;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-4);
|
||||
--button-height: auto;
|
||||
--button-padding: var(--spacing-3) var(--spacing-5);
|
||||
--button-border-radius: 8px;
|
||||
--button-font-weight: var(--font-weight-normal);
|
||||
--button-size-line-height: 20px;
|
||||
--button-white-space: normal;
|
||||
--button-label-white-space: normal;
|
||||
--button-variant-ghost-background-color: var(--l3-background-60);
|
||||
--button-variant-ghost-color: var(--l2-foreground);
|
||||
--button-variant-ghost-hover-background-color: var(--l3-background);
|
||||
--button-variant-ghost-hover-color: var(--l1-foreground);
|
||||
|
||||
// Buttons don't inherit the page font, and long prompts wrap from the left.
|
||||
> [data-slot='button'] {
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
// The same box as a chip; the prompt inside is invisible and only sets the size.
|
||||
.suggestionSkeleton {
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
padding: var(--spacing-3) var(--spacing-5);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.005em;
|
||||
color: transparent;
|
||||
user-select: none;
|
||||
|
||||
// Nested so it outranks the skeleton's own `position: relative`.
|
||||
.suggestionSkeletonFill {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
--skeleton-background: var(--l3-background-60);
|
||||
--skeleton-shimmer-color: color-mix(
|
||||
in srgb,
|
||||
var(--l2-foreground),
|
||||
transparent 92%
|
||||
);
|
||||
--skeleton-input-height: 100%;
|
||||
--skeleton-input-border-radius: 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import cx from 'classnames';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Skeleton } from '@signozhq/ui/skeleton';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
|
||||
import { AIAssistantEvents, SuggestedPromptCategory } from '../../../events';
|
||||
import { useVariant } from '../../../VariantContext';
|
||||
import DotGrid from '../../DotGrid/DotGrid';
|
||||
import { EMPTY_STATE_CHIPS_FALLBACK } from '../emptyStateChipsFallback';
|
||||
import { useEmptyStateChips } from '../useEmptyStateChips';
|
||||
|
||||
import styles from './EmptyState.module.scss';
|
||||
|
||||
interface EmptyStateProps {
|
||||
onSendSuggestedPrompt: (text: string) => void;
|
||||
/** Fading out after the first send; shows the cached prompts without fetching. */
|
||||
isLeaving?: boolean;
|
||||
}
|
||||
|
||||
/** A new chat: the dot grid, Noz's intro and the suggested prompts. */
|
||||
export default function EmptyState({
|
||||
onSendSuggestedPrompt,
|
||||
isLeaving = false,
|
||||
}: EmptyStateProps): JSX.Element {
|
||||
const isCompact = useVariant() === 'panel';
|
||||
const { chips: emptyStateChips, isLoading: areChipsLoading } =
|
||||
useEmptyStateChips(!isLeaving);
|
||||
// Fading out before the prompts arrived: keep the placeholders that were showing.
|
||||
const showsSkeletons =
|
||||
areChipsLoading || (isLeaving && emptyStateChips.length === 0);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.empty, { [styles.compact]: isCompact })}>
|
||||
<DotGrid className={styles.dotGrid} />
|
||||
<div className={styles.emptyContent}>
|
||||
<div className={styles.emptyIntro}>
|
||||
<div className={styles.emptyHeading}>
|
||||
<h3 className={styles.emptyTitle}>Noz</h3>
|
||||
<Badge color="secondary" variant="outlined">
|
||||
beta
|
||||
</Badge>
|
||||
</div>
|
||||
<p className={styles.emptySubtitle}>
|
||||
I can help you understand and be the all-seeing eye over your entire
|
||||
infrastructure. What do you need?
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.suggestions} aria-busy={areChipsLoading}>
|
||||
{/* As many as the backend sends, each the width of a real prompt. */}
|
||||
{showsSkeletons &&
|
||||
EMPTY_STATE_CHIPS_FALLBACK.map((chip) => (
|
||||
<span
|
||||
key={chip.id}
|
||||
className={styles.suggestionSkeleton}
|
||||
data-testid="empty-state-chip-skeleton"
|
||||
aria-hidden
|
||||
>
|
||||
<Skeleton.Input
|
||||
active
|
||||
block
|
||||
className={styles.suggestionSkeletonFill}
|
||||
/>
|
||||
{chip.text}
|
||||
</span>
|
||||
))}
|
||||
{emptyStateChips.map((chip) => (
|
||||
<Button
|
||||
key={chip.id}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={(): void => {
|
||||
void logEvent(AIAssistantEvents.SuggestedPromptClicked, {
|
||||
promptId: chip.id,
|
||||
category: SuggestedPromptCategory.EmptyState,
|
||||
});
|
||||
onSendSuggestedPrompt(chip.text);
|
||||
}}
|
||||
testId={`empty-state-chip-${chip.id}`}
|
||||
>
|
||||
{chip.text}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,64 +15,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
// The full page keeps the thread in a 672px column; the extra 32px is the rows' own gutters.
|
||||
.row.fullPage {
|
||||
max-width: 704px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
// The list, and right after the first send the empty state fading out over it.
|
||||
.thread {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.emptyStateExit {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 24px;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
animation: emptyStateExit ease-out forwards;
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.emptyTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--l1-foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.emptySubtitle {
|
||||
font-size: 13px;
|
||||
color: var(--l3-foreground);
|
||||
margin: 0 0 12px;
|
||||
max-width: 320px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.suggestion {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l1-background);
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12.5px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background);
|
||||
border-color: var(--l3-border);
|
||||
color: var(--l1-foreground);
|
||||
@keyframes emptyStateExit {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import cx from 'classnames';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
|
||||
import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
|
||||
import { AIAssistantEvents } from '../../events';
|
||||
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
import { Message, StreamingEventItem } from '../../types';
|
||||
import { useVariant } from '../../VariantContext';
|
||||
import MessageBubble from '../MessageBubble';
|
||||
import StreamingMessage from '../StreamingMessage';
|
||||
|
||||
import EmptyState from './EmptyState/EmptyState';
|
||||
import styles from './VirtualizedMessages.module.scss';
|
||||
import { useEmptyStateChips } from './useEmptyStateChips';
|
||||
import { useFirstMessageFloat } from './useFirstMessageFloat';
|
||||
|
||||
const EMPTY_EVENTS: StreamingEventItem[] = [];
|
||||
const EMPTY_STATE_EXIT_MS = 400;
|
||||
|
||||
interface VirtualizedMessagesProps {
|
||||
conversationId: string;
|
||||
messages: Message[];
|
||||
isStreaming: boolean;
|
||||
/** When this chat's first message went out; plays the empty state's exit. */
|
||||
firstSendAt: number | null;
|
||||
/**
|
||||
* Called when a user clicks an empty-state suggested prompt. Routed
|
||||
* through the parent so analytics (Message sent) fire with the same
|
||||
@@ -32,6 +37,7 @@ export default function VirtualizedMessages({
|
||||
conversationId,
|
||||
messages,
|
||||
isStreaming,
|
||||
firstSendAt,
|
||||
onSendSuggestedPrompt,
|
||||
}: VirtualizedMessagesProps): JSX.Element {
|
||||
const regenerateAssistantMessage = useAIAssistantStore(
|
||||
@@ -73,6 +79,18 @@ export default function VirtualizedMessages({
|
||||
// the user was scrolled — sending a message and not seeing it is worse
|
||||
// than the anti-yank guarantee.
|
||||
const lastSeenUserMessageIdRef = useRef<string | null>(null);
|
||||
const threadRef = useRef<HTMLDivElement>(null);
|
||||
// The fading copy is for show: no focus, no clicks, nothing announced.
|
||||
const makeInert = useCallback((element: HTMLDivElement | null): void => {
|
||||
element?.setAttribute('inert', '');
|
||||
}, []);
|
||||
const isFullPage = useVariant() === 'page';
|
||||
const [hasEmptyStateExited, setHasEmptyStateExited] = useState(false);
|
||||
// How far into the exit a remount lands; fixed per mount so re-renders don't skip ahead.
|
||||
const [exitOffsetMs] = useState(() =>
|
||||
firstSendAt === null ? 0 : Date.now() - firstSendAt,
|
||||
);
|
||||
useFirstMessageFloat(threadRef, messages[0]?.id, firstSendAt);
|
||||
|
||||
const handleRegenerate = useCallback(
|
||||
(messageId: string): void => {
|
||||
@@ -155,87 +173,88 @@ export default function VirtualizedMessages({
|
||||
const showStreamingSlot =
|
||||
isStreaming || Boolean(pendingApproval) || Boolean(pendingClarification);
|
||||
const isEmptyState = messages.length === 0 && !showStreamingSlot;
|
||||
const { chips: emptyStateChips } = useEmptyStateChips(isEmptyState);
|
||||
|
||||
if (isEmptyState) {
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
<div className={`${styles.emptyIcon} noz-wave`}>
|
||||
<Noz size={48} />
|
||||
</div>
|
||||
<h3 className={styles.emptyTitle}>Noz</h3>
|
||||
<p className={styles.emptySubtitle}>
|
||||
Ask questions about your traces, logs, metrics, and infrastructure.
|
||||
</p>
|
||||
<div className={styles.suggestions}>
|
||||
{emptyStateChips.map((chip) => (
|
||||
<div
|
||||
key={chip.id}
|
||||
className={styles.suggestion}
|
||||
onClick={(): void => {
|
||||
void logEvent(AIAssistantEvents.SuggestedPromptClicked, {
|
||||
promptId: chip.id,
|
||||
category: SuggestedPromptCategory.EmptyState,
|
||||
});
|
||||
onSendSuggestedPrompt(chip.text);
|
||||
}}
|
||||
data-testid={`empty-state-chip-${chip.id}`}
|
||||
>
|
||||
{chip.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <EmptyState onSendSuggestedPrompt={onSendSuggestedPrompt} />;
|
||||
}
|
||||
|
||||
const totalCount = messages.length + (showStreamingSlot ? 1 : 0);
|
||||
const isEmptyStateExiting =
|
||||
!hasEmptyStateExited &&
|
||||
firstSendAt !== null &&
|
||||
Date.now() - firstSendAt < EMPTY_STATE_EXIT_MS &&
|
||||
!window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
scrollerRef={(ref): void => {
|
||||
scrollerRef.current = ref;
|
||||
}}
|
||||
className={styles.messages}
|
||||
totalCount={totalCount}
|
||||
followOutput={followOutput}
|
||||
atBottomStateChange={handleAtBottomStateChange}
|
||||
atBottomThreshold={64}
|
||||
initialTopMostItemIndex={Math.max(0, totalCount - 1)}
|
||||
itemContent={(index): JSX.Element => {
|
||||
if (index < messages.length) {
|
||||
const msg = messages[index];
|
||||
const isLastAssistant =
|
||||
msg.role === 'assistant' &&
|
||||
messages.slice(index + 1).every((m) => m.role !== 'assistant');
|
||||
<div ref={threadRef} className={styles.thread}>
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
scrollerRef={(ref): void => {
|
||||
scrollerRef.current = ref;
|
||||
}}
|
||||
className={styles.messages}
|
||||
totalCount={totalCount}
|
||||
followOutput={followOutput}
|
||||
atBottomStateChange={handleAtBottomStateChange}
|
||||
atBottomThreshold={64}
|
||||
initialTopMostItemIndex={Math.max(0, totalCount - 1)}
|
||||
itemContent={(index): JSX.Element => {
|
||||
const rowClass = cx(styles.row, { [styles.fullPage]: isFullPage });
|
||||
if (index < messages.length) {
|
||||
const msg = messages[index];
|
||||
const isLastAssistant =
|
||||
msg.role === 'assistant' &&
|
||||
messages.slice(index + 1).every((m) => m.role !== 'assistant');
|
||||
return (
|
||||
<div className={rowClass}>
|
||||
<MessageBubble
|
||||
message={msg}
|
||||
onRegenerate={
|
||||
isLastAssistant && !showStreamingSlot
|
||||
? (): void => handleRegenerate(msg.id)
|
||||
: undefined
|
||||
}
|
||||
onRetry={
|
||||
msg.isError && isLastAssistant && !showStreamingSlot
|
||||
? handleRetry
|
||||
: undefined
|
||||
}
|
||||
isLastAssistant={isLastAssistant}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MessageBubble
|
||||
message={msg}
|
||||
onRegenerate={
|
||||
isLastAssistant && !showStreamingSlot
|
||||
? (): void => handleRegenerate(msg.id)
|
||||
: undefined
|
||||
}
|
||||
onRetry={
|
||||
msg.isError && isLastAssistant && !showStreamingSlot
|
||||
? handleRetry
|
||||
: undefined
|
||||
}
|
||||
isLastAssistant={isLastAssistant}
|
||||
/>
|
||||
<div className={rowClass}>
|
||||
<StreamingMessage
|
||||
conversationId={conversationId}
|
||||
events={streamingEvents}
|
||||
status={streamingStatus}
|
||||
pendingApproval={pendingApproval}
|
||||
pendingClarification={pendingClarification}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<StreamingMessage
|
||||
conversationId={conversationId}
|
||||
events={streamingEvents}
|
||||
status={streamingStatus}
|
||||
pendingApproval={pendingApproval}
|
||||
pendingClarification={pendingClarification}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
/>
|
||||
{isEmptyStateExiting && (
|
||||
<div
|
||||
ref={makeInert}
|
||||
className={styles.emptyStateExit}
|
||||
style={{
|
||||
animationDuration: `${EMPTY_STATE_EXIT_MS}ms`,
|
||||
animationDelay: `-${exitOffsetMs}ms`,
|
||||
}}
|
||||
onAnimationEnd={(event): void => {
|
||||
if (event.target === event.currentTarget) {
|
||||
setHasEmptyStateExited(true);
|
||||
}
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
<EmptyState isLeaving onSendSuggestedPrompt={onSendSuggestedPrompt} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,12 +11,13 @@ import { EMPTY_STATE_CHIPS_FALLBACK } from './emptyStateChipsFallback';
|
||||
|
||||
interface UseEmptyStateChipsResult {
|
||||
chips: ChipDTO[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function useEmptyStateChips(enabled: boolean): UseEmptyStateChipsResult {
|
||||
const pageType = useResolvePageType();
|
||||
|
||||
const { data, isError } = useQuery(
|
||||
const { data, isError, isLoading } = useQuery(
|
||||
[REACT_QUERY_KEY.AI_ASSISTANT_EMPTY_STATE_CHIPS, pageType],
|
||||
({ signal }) => getEmptyStateChips(pageType, signal),
|
||||
{ enabled },
|
||||
@@ -29,5 +30,5 @@ export function useEmptyStateChips(enabled: boolean): UseEmptyStateChipsResult {
|
||||
return data ?? [];
|
||||
}, [data, isError]);
|
||||
|
||||
return { chips };
|
||||
return { chips, isLoading };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { RefObject, useLayoutEffect } from 'react';
|
||||
|
||||
const FIRST_MESSAGE_FLOAT_MS = 550;
|
||||
|
||||
// When each first message started floating, so a remounted list resumes the same flight.
|
||||
const floatStarts = new Map<string, number>();
|
||||
|
||||
/** Floats the chat's first message up from just above the input to its place. */
|
||||
export function useFirstMessageFloat(
|
||||
threadRef: RefObject<HTMLElement>,
|
||||
messageId: string | undefined,
|
||||
sentAt: number | null,
|
||||
): void {
|
||||
useLayoutEffect(() => {
|
||||
const thread = threadRef.current;
|
||||
if (
|
||||
!messageId ||
|
||||
sentAt === null ||
|
||||
!thread ||
|
||||
Date.now() - (floatStarts.get(messageId) ?? sentAt) >=
|
||||
FIRST_MESSAGE_FLOAT_MS * 2 ||
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let animation: Animation | undefined;
|
||||
const float = (): boolean => {
|
||||
const bubble = thread.querySelector<HTMLElement>(
|
||||
`[data-testid="ai-message-${messageId}"]`,
|
||||
);
|
||||
if (!bubble) {
|
||||
return false;
|
||||
}
|
||||
const rise =
|
||||
thread.getBoundingClientRect().bottom -
|
||||
bubble.getBoundingClientRect().bottom;
|
||||
animation = bubble.animate(
|
||||
[
|
||||
{ transform: `translateY(${Math.max(0, rise)}px)`, opacity: 0 },
|
||||
{ transform: 'translateY(0)', opacity: 1 },
|
||||
],
|
||||
{
|
||||
duration: FIRST_MESSAGE_FLOAT_MS,
|
||||
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
fill: 'backwards',
|
||||
},
|
||||
);
|
||||
if (!floatStarts.has(messageId)) {
|
||||
const now = Date.now();
|
||||
floatStarts.forEach((start, id) => {
|
||||
if (now - start >= FIRST_MESSAGE_FLOAT_MS * 2) {
|
||||
floatStarts.delete(id);
|
||||
}
|
||||
});
|
||||
floatStarts.set(messageId, now);
|
||||
}
|
||||
animation.currentTime = Date.now() - (floatStarts.get(messageId) ?? 0);
|
||||
return true;
|
||||
};
|
||||
|
||||
if (float()) {
|
||||
return (): void => animation?.cancel();
|
||||
}
|
||||
// The list renders its rows a beat later; catch the bubble before it paints.
|
||||
const observer = new MutationObserver(() => {
|
||||
if (float()) {
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
observer.observe(thread, { childList: true, subtree: true });
|
||||
const timeout = window.setTimeout(
|
||||
() => observer.disconnect(),
|
||||
FIRST_MESSAGE_FLOAT_MS,
|
||||
);
|
||||
return (): void => {
|
||||
observer.disconnect();
|
||||
window.clearTimeout(timeout);
|
||||
animation?.cancel();
|
||||
};
|
||||
}, [threadRef, messageId, sentAt]);
|
||||
}
|
||||
@@ -4,58 +4,6 @@
|
||||
* requested, Limit hit) are not declared here — they fire from the AI service.
|
||||
*/
|
||||
|
||||
export interface BrowserInfo {
|
||||
browserName: string;
|
||||
browserVersion: string;
|
||||
}
|
||||
|
||||
type NavigatorWithBrandHints = Navigator & {
|
||||
userAgentData?: { brands: { brand: string; version: string }[] };
|
||||
brave?: { isBrave: () => Promise<boolean> };
|
||||
};
|
||||
|
||||
/**
|
||||
* We mainly need to distinguish Chrome / Edge (Speech API works) from Chromium
|
||||
* derivatives (no Google API key → voice fails with `network`). UA sniffing is
|
||||
* the source of truth for derivative identification; `userAgentData` is used
|
||||
* only as a fast happy path for Chrome / Edge. Brave needs its own probe — it
|
||||
* advertises Chrome in both UA and brand hints.
|
||||
*/
|
||||
export function getBrowserInfo(): BrowserInfo {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return { browserName: 'unknown', browserVersion: 'unknown' };
|
||||
}
|
||||
const nav = navigator as NavigatorWithBrandHints;
|
||||
const ua = nav.userAgent;
|
||||
|
||||
// Order matters: derivatives put "Chrome" in their UA; Chrome puts "Safari".
|
||||
const matchers: { name: string; re: RegExp }[] = [
|
||||
{ name: 'Edge', re: /Edg(?:e|A|iOS)?\/([\d.]+)/ },
|
||||
{ name: 'Opera', re: /OPR\/([\d.]+)/ },
|
||||
{ name: 'Vivaldi', re: /Vivaldi\/([\d.]+)/ },
|
||||
{ name: 'Chrome', re: /Chrome\/([\d.]+)/ },
|
||||
{ name: 'Firefox', re: /Firefox\/([\d.]+)/ },
|
||||
{ name: 'Safari', re: /Version\/([\d.]+).*Safari/ },
|
||||
];
|
||||
let browserName = 'unknown';
|
||||
let browserVersion = 'unknown';
|
||||
for (const { name, re } of matchers) {
|
||||
const m = ua.match(re);
|
||||
if (m) {
|
||||
browserName = name;
|
||||
browserVersion = m[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Brave hides as Chrome in UA + brand hints; its probe is the only tell.
|
||||
if (nav.brave?.isBrave) {
|
||||
browserName = 'Brave';
|
||||
}
|
||||
|
||||
return { browserName, browserVersion };
|
||||
}
|
||||
|
||||
export const SuggestedPromptCategory = {
|
||||
FollowUp: 'follow_up',
|
||||
EmptyState: 'empty_state',
|
||||
@@ -76,15 +24,6 @@ export const AIAssistantOpenSource = {
|
||||
export type AIAssistantOpenSource =
|
||||
(typeof AIAssistantOpenSource)[keyof typeof AIAssistantOpenSource];
|
||||
|
||||
// `source` attribute on the `VoiceInputUsed` event — which surface initiated
|
||||
// the recording.
|
||||
export const VoiceInputSource = {
|
||||
Button: 'button',
|
||||
Shortcut: 'shortcut',
|
||||
} as const;
|
||||
export type VoiceInputSource =
|
||||
(typeof VoiceInputSource)[keyof typeof VoiceInputSource];
|
||||
|
||||
export enum AIAssistantEvents {
|
||||
Opened = 'AI Assistant: Opened',
|
||||
MessageSent = 'AI Assistant: Message sent',
|
||||
@@ -98,7 +37,5 @@ export enum AIAssistantEvents {
|
||||
DocOpened = 'AI Assistant: Doc opened',
|
||||
ApplyFilterClicked = 'AI Assistant: Apply filter clicked',
|
||||
ThreadOpenedFromHistory = 'AI Assistant: Thread opened from history',
|
||||
VoiceInputUsed = 'AI Assistant: Voice input used',
|
||||
VoiceInputFailed = 'AI Assistant: Voice input failed',
|
||||
NewChatClicked = 'AI Assistant: New chat clicked',
|
||||
}
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
// ── Web Speech API types (not yet in lib.dom.d.ts) ────────────────────────────
|
||||
|
||||
interface SpeechRecognitionResult {
|
||||
readonly length: number;
|
||||
readonly isFinal: boolean;
|
||||
[index: number]: { transcript: string; confidence: number };
|
||||
}
|
||||
|
||||
interface SpeechRecognitionResultList {
|
||||
readonly length: number;
|
||||
[index: number]: SpeechRecognitionResult;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionEvent extends Event {
|
||||
readonly resultIndex: number;
|
||||
readonly results: SpeechRecognitionResultList;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionErrorEvent extends Event {
|
||||
readonly error: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
interface ISpeechRecognition extends EventTarget {
|
||||
lang: string;
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
onstart: (() => void) | null;
|
||||
onend: (() => void) | null;
|
||||
onresult: ((event: SpeechRecognitionEvent) => void) | null;
|
||||
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
type SpeechRecognitionConstructor = new () => ISpeechRecognition;
|
||||
|
||||
// ── Vendor-prefix shim for Safari / older browsers ────────────────────────────
|
||||
|
||||
// Some hardened/enterprise browsers install a getter
|
||||
// on window.SpeechRecognition that THROWS on access ("Web Speech API is disabled
|
||||
// due to your security policy") instead of leaving the property undefined.
|
||||
// Because this resolves at module-evaluation time, an uncaught throw here aborts
|
||||
// the entire bundle and the app renders a blank page. Read defensively so a
|
||||
// throwing getter degrades to "unsupported" rather than crashing the app.
|
||||
function resolveSpeechRecognitionAPI(): SpeechRecognitionConstructor | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).SpeechRecognition ??
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).webkitSpeechRecognition ??
|
||||
null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const SpeechRecognitionAPI: SpeechRecognitionConstructor | null =
|
||||
resolveSpeechRecognitionAPI();
|
||||
|
||||
export type SpeechRecognitionError =
|
||||
| 'not-supported'
|
||||
| 'not-allowed'
|
||||
| 'no-speech'
|
||||
| 'network'
|
||||
| 'unknown';
|
||||
|
||||
export type MicrophonePermission = 'prompt' | 'granted' | 'denied';
|
||||
|
||||
interface UseSpeechRecognitionOptions {
|
||||
onError?: (error: SpeechRecognitionError) => void;
|
||||
/**
|
||||
* Called directly from browser recognition events — no React state intermediary.
|
||||
* `isFinal=false` → interim (live preview), `isFinal=true` → committed text.
|
||||
*/
|
||||
onTranscript?: (text: string, isFinal: boolean) => void;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
interface UseSpeechRecognitionReturn {
|
||||
isListening: boolean;
|
||||
isSupported: boolean;
|
||||
/**
|
||||
* Browser microphone permission. Starts as `'prompt'` (or whatever the
|
||||
* Permissions API reports on mount), flips to `'granted'` once a session
|
||||
* successfully starts, and to `'denied'` if the user blocks access.
|
||||
*/
|
||||
permission: MicrophonePermission;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
/** Stop recognition and discard any pending interim text (no onTranscript call). */
|
||||
discard: () => void;
|
||||
}
|
||||
|
||||
export function useSpeechRecognition({
|
||||
onError,
|
||||
onTranscript,
|
||||
lang = 'en-US',
|
||||
}: UseSpeechRecognitionOptions = {}): UseSpeechRecognitionReturn {
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [permission, setPermission] = useState<MicrophonePermission>('prompt');
|
||||
const recognitionRef = useRef<ISpeechRecognition | null>(null);
|
||||
const isDiscardingRef = useRef(false);
|
||||
const isSupported = SpeechRecognitionAPI !== null;
|
||||
|
||||
// Seed `permission` from the Permissions API and keep it in sync with
|
||||
// external changes (e.g. user resets the site permission in browser
|
||||
// settings). Falls back silently if the API is unavailable (Safari) —
|
||||
// the recognition lifecycle below will still flip the state on grant
|
||||
// or deny.
|
||||
useEffect(() => {
|
||||
if (typeof navigator === 'undefined' || !navigator.permissions?.query) {
|
||||
return undefined;
|
||||
}
|
||||
let status: PermissionStatus | null = null;
|
||||
let cancelled = false;
|
||||
const handleChange = (): void => {
|
||||
if (status) {
|
||||
setPermission(status.state as MicrophonePermission);
|
||||
}
|
||||
};
|
||||
navigator.permissions
|
||||
.query({ name: 'microphone' as PermissionName })
|
||||
.then((permStatus) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
status = permStatus;
|
||||
setPermission(permStatus.state as MicrophonePermission);
|
||||
permStatus.addEventListener('change', handleChange);
|
||||
})
|
||||
.catch(() => {
|
||||
// Permissions API rejected (some browsers don't recognise the
|
||||
// 'microphone' descriptor) — leave permission as 'prompt'.
|
||||
});
|
||||
return (): void => {
|
||||
cancelled = true;
|
||||
status?.removeEventListener('change', handleChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Always-current refs — updated synchronously on every render so closures
|
||||
// inside recognition event handlers always call the latest version.
|
||||
const onErrorRef = useRef(onError);
|
||||
onErrorRef.current = onError;
|
||||
|
||||
const onTranscriptRef = useRef(onTranscript);
|
||||
onTranscriptRef.current = onTranscript;
|
||||
|
||||
const stop = useCallback(() => {
|
||||
recognitionRef.current?.stop();
|
||||
}, []);
|
||||
|
||||
const discard = useCallback(() => {
|
||||
isDiscardingRef.current = true;
|
||||
recognitionRef.current?.stop();
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const start = useCallback(() => {
|
||||
if (!isSupported) {
|
||||
onErrorRef.current?.('not-supported');
|
||||
return;
|
||||
}
|
||||
|
||||
// If already listening, stop
|
||||
if (recognitionRef.current) {
|
||||
recognitionRef.current.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const recognition = new SpeechRecognitionAPI!();
|
||||
recognition.lang = lang;
|
||||
recognition.continuous = true; // keep listening until user clicks stop
|
||||
recognition.interimResults = true; // live updates while speaking
|
||||
|
||||
// Track the last interim text so we can commit it as final in onend —
|
||||
// Chrome often skips the isFinal result when stop() is called manually.
|
||||
let pendingInterim = '';
|
||||
|
||||
recognition.onstart = (): void => {
|
||||
setIsListening(true);
|
||||
setPermission('granted');
|
||||
};
|
||||
|
||||
recognition.onresult = (event): void => {
|
||||
// Browser fires a final `onresult` after `recognition.stop()` —
|
||||
// when the caller invoked `discard()` (e.g. stop-and-send) the
|
||||
// React state has already been cleared / the message has
|
||||
// already been dispatched, so a late transcript would
|
||||
// repopulate the textarea. Drop these events while discarding.
|
||||
if (isDiscardingRef.current) {
|
||||
pendingInterim = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let interim = '';
|
||||
let finalText = '';
|
||||
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const text = event.results[i][0].transcript;
|
||||
if (event.results[i].isFinal) {
|
||||
finalText += text;
|
||||
} else {
|
||||
interim += text;
|
||||
}
|
||||
}
|
||||
|
||||
if (finalText) {
|
||||
pendingInterim = '';
|
||||
onTranscriptRef.current?.(finalText, true);
|
||||
} else if (interim) {
|
||||
pendingInterim = interim;
|
||||
onTranscriptRef.current?.(interim, false);
|
||||
}
|
||||
};
|
||||
|
||||
recognition.onerror = (event): void => {
|
||||
pendingInterim = '';
|
||||
let mapped: SpeechRecognitionError = 'unknown';
|
||||
if (event.error === 'not-allowed' || event.error === 'service-not-allowed') {
|
||||
mapped = 'not-allowed';
|
||||
setPermission('denied');
|
||||
} else if (event.error === 'no-speech') {
|
||||
mapped = 'no-speech';
|
||||
} else if (event.error === 'network') {
|
||||
mapped = 'network';
|
||||
}
|
||||
onErrorRef.current?.(mapped);
|
||||
};
|
||||
|
||||
recognition.onend = (): void => {
|
||||
// Commit any interim text that never received a final result,
|
||||
// unless the session was explicitly discarded.
|
||||
if (!isDiscardingRef.current && pendingInterim) {
|
||||
const committed = pendingInterim;
|
||||
pendingInterim = '';
|
||||
onTranscriptRef.current?.(committed, true);
|
||||
}
|
||||
isDiscardingRef.current = false;
|
||||
pendingInterim = '';
|
||||
setIsListening(false);
|
||||
recognitionRef.current = null;
|
||||
};
|
||||
|
||||
recognitionRef.current = recognition;
|
||||
recognition.start();
|
||||
}, [isSupported, lang]);
|
||||
|
||||
// Clean up on unmount
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
recognitionRef.current?.abort();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { isListening, isSupported, permission, start, stop, discard };
|
||||
}
|
||||
@@ -4,11 +4,9 @@
|
||||
*/
|
||||
|
||||
import { AI_API_PATH, setAIBackendUrl } from 'api/AIAPIInstance';
|
||||
import removeSessionStorage from 'api/browser/sessionstorage/remove';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { rest, type RequestHandler } from 'msw';
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import {
|
||||
choiceControl,
|
||||
@@ -272,58 +270,3 @@ export const longActionTooltipHandlers: RequestHandler[] = onBothBases(
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/** The recognizer `.storybook/preview-head.html` installs in place of the browser's. */
|
||||
interface StorySpeechRecognition {
|
||||
listening: {
|
||||
hear: (transcript: string) => void;
|
||||
onerror: ((event: { error: string }) => void) | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the composer's mic and waits for the recording to start. The composer
|
||||
* stays disabled until the thread has loaded.
|
||||
*/
|
||||
export const startVoiceInput = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
const mic = await waitFor(
|
||||
async () => {
|
||||
const button = canvas.getByRole('button', { name: /start voice input/i });
|
||||
|
||||
// Depending on its version, Button disables through `disabled` or `aria-disabled`.
|
||||
await expect(button).toBeEnabled();
|
||||
await expect(button).not.toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
return button;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
await userEvent.click(mic);
|
||||
await canvas.findByRole('status', { name: /recording voice input/i });
|
||||
};
|
||||
|
||||
/** Speaks into the open recording: the words so far, still listening. */
|
||||
export const speak = (transcript: string): void => {
|
||||
(
|
||||
window as unknown as { webkitSpeechRecognition: StorySpeechRecognition }
|
||||
).webkitSpeechRecognition.listening?.hear(transcript);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears the flag `ChatInput` keeps in sessionStorage after a failed recording,
|
||||
* which hides the mic for the rest of the tab, test-runner's included.
|
||||
*/
|
||||
export const forgetVoiceFailure = (): void => {
|
||||
removeSessionStorage('ai-assistant-voice-unavailable');
|
||||
};
|
||||
|
||||
/** Fails the open recording the way the browser's recognizer reports it. */
|
||||
export const failVoiceInput = (error: 'network' | 'not-allowed'): void => {
|
||||
(
|
||||
window as unknown as { webkitSpeechRecognition: StorySpeechRecognition }
|
||||
).webkitSpeechRecognition.listening?.onerror?.({ error });
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { renderAtRoute } from '@/storybook/renderAtRoute';
|
||||
import { screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -9,13 +8,17 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import AIAssistantPage from '../AIAssistantPage';
|
||||
import {
|
||||
aiAssistantMocks,
|
||||
failVoiceInput,
|
||||
forgetVoiceFailure,
|
||||
longActionTooltipHandlers,
|
||||
speak,
|
||||
startVoiceInput,
|
||||
} from './AIAssistantPage.stories.mocks';
|
||||
import type { ThreadPart } from './__story_mockdata__/aiAssistant';
|
||||
import {
|
||||
copyConversationLink,
|
||||
expandActivity,
|
||||
openApprovalDiff,
|
||||
openContextPicker,
|
||||
openConversationActions,
|
||||
openNegativeFeedback,
|
||||
} from './__story_mockdata__/aiAssistant.play';
|
||||
|
||||
type AIAssistantArgs = PageStoryArgs<typeof aiAssistantMocks>;
|
||||
|
||||
@@ -40,40 +43,12 @@ const meta = {
|
||||
),
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
beforeEach: forgetVoiceFailure,
|
||||
} satisfies Meta<AIAssistantArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<AIAssistantArgs>;
|
||||
|
||||
/** The thread list resolves before the thread does, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/** How long what a click opened has to stay on screen to count as open. */
|
||||
const HOLD_MS = 1_000;
|
||||
|
||||
/**
|
||||
* Click something, and keep clicking until what it opens is on screen. The
|
||||
* message list remounts its items while it measures a freshly loaded thread, so
|
||||
* a single click can land on a row that is about to be replaced, taking the
|
||||
* state it just set with it. The replacement can land after that state has
|
||||
* rendered, so it has to still be there `HOLD_MS` later.
|
||||
*/
|
||||
const clickUntil = async (
|
||||
find: () => Promise<HTMLElement>,
|
||||
opens: RegExp,
|
||||
): Promise<void> => {
|
||||
await waitFor(async () => {
|
||||
await userEvent.click(await find());
|
||||
await screen.findByText(opens, undefined, { timeout: 1_000 });
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, HOLD_MS);
|
||||
});
|
||||
screen.getByText(opens);
|
||||
}, untilLoaded);
|
||||
};
|
||||
|
||||
/** The blocks the agent renders as cards the user answers in place. */
|
||||
const INTERACTIVE: ThreadPart[] = [
|
||||
'question',
|
||||
@@ -149,33 +124,7 @@ export const LoadingThread: Story = {
|
||||
* with what went in and what came back.
|
||||
*/
|
||||
export const ActivityExpanded: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await clickUntil(
|
||||
() => canvas.findByText(/worked through/i, undefined, untilLoaded),
|
||||
/compared checkout p99/i,
|
||||
);
|
||||
await clickUntil(
|
||||
() => canvas.findByText(/compared checkout p99/i, undefined, untilLoaded),
|
||||
/^Output$/,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Opens the approval card's diff dialog. */
|
||||
const openApprovalDiff: NonNullable<Story['play']> = async ({
|
||||
canvasElement,
|
||||
}) => {
|
||||
await clickUntil(
|
||||
() =>
|
||||
within(canvasElement).findByLabelText(
|
||||
/expand diff/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
/approval diff/i,
|
||||
);
|
||||
play: expandActivity,
|
||||
};
|
||||
|
||||
/** The approval diff at full size, before against after. */
|
||||
@@ -184,66 +133,14 @@ export const ApprovalDiff: Story = {
|
||||
play: openApprovalDiff,
|
||||
};
|
||||
|
||||
/**
|
||||
* Dictating a question: the mic listening, the words heard so far in the
|
||||
* composer, and the controls to discard them or stop and send.
|
||||
*/
|
||||
export const VoiceRecording: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await startVoiceInput(canvasElement);
|
||||
speak('why did checkout p99 jump after the 14:00 deploy');
|
||||
await within(canvasElement).findByDisplayValue(/checkout p99 jump/);
|
||||
},
|
||||
};
|
||||
|
||||
/** The recognizer reporting it cannot reach the speech service. */
|
||||
export const VoiceInputUnavailableToast: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await startVoiceInput(canvasElement);
|
||||
failVoiceInput('network');
|
||||
await screen.findByText(/voice input unavailable in this browser/i);
|
||||
},
|
||||
};
|
||||
|
||||
/** The browser refusing the microphone. */
|
||||
export const MicrophoneDeniedToast: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await startVoiceInput(canvasElement);
|
||||
failVoiceInput('not-allowed');
|
||||
await screen.findByText(/microphone access denied/i);
|
||||
},
|
||||
};
|
||||
|
||||
/** The comment box a thumbs down opens, which a thumbs up does not. */
|
||||
export const NegativeFeedback: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickUntil(async () => {
|
||||
// Every assistant message carries the bar; only the last one shows it
|
||||
// without a hover.
|
||||
const bars = await within(canvasElement).findAllByLabelText(
|
||||
/bad response/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
return bars[bars.length - 1];
|
||||
}, /what went wrong/i);
|
||||
},
|
||||
play: openNegativeFeedback,
|
||||
};
|
||||
|
||||
/** What a conversation row offers: rename, a link to it, and archiving. */
|
||||
export const ConversationActions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickUntil(async () => {
|
||||
const [actions] = await within(canvasElement).findAllByLabelText(
|
||||
/conversation actions/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
return actions;
|
||||
}, /copy link/i);
|
||||
},
|
||||
play: openConversationActions,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -251,39 +148,17 @@ export const ConversationActions: Story = {
|
||||
* can be pinned to.
|
||||
*/
|
||||
export const AddContext: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickUntil(
|
||||
() =>
|
||||
within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: /add context/i },
|
||||
untilLoaded,
|
||||
),
|
||||
/checkout overview/i,
|
||||
);
|
||||
},
|
||||
play: openContextPicker,
|
||||
};
|
||||
|
||||
/** "Copy link" on a conversation row, which toasts once the link is copied. */
|
||||
export const ConversationLinkCopiedToast: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await clickUntil(async () => {
|
||||
const [actions] = await within(canvasElement).findAllByLabelText(
|
||||
/conversation actions/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
return actions;
|
||||
}, /copy link/i);
|
||||
await userEvent.click(await screen.findByText(/copy link/i));
|
||||
await screen.findByText(/conversation link copied to clipboard/i);
|
||||
},
|
||||
play: copyConversationLink,
|
||||
};
|
||||
|
||||
/**
|
||||
* Every tooltip the thread carries, held open at once: the composer's voice and
|
||||
* send buttons, the sidebar's new conversation, the copy chip under each user
|
||||
* Every tooltip the thread carries, held open at once: the composer's add context
|
||||
* and send buttons, the sidebar's new conversation, the copy chip under each user
|
||||
* message, the copy, rate and regenerate bar under each answer, the code block's
|
||||
* copy, and the action chip's own description.
|
||||
*/
|
||||
@@ -301,13 +176,3 @@ export const TooltipsInApprovalDiff: Story = {
|
||||
args: { tooltipsOpen: true, agent: 'awaiting-approval', contents: BRIEF },
|
||||
play: openApprovalDiff,
|
||||
};
|
||||
|
||||
/**
|
||||
* The recording controls' tooltips, held open: discard, stop and send, and the
|
||||
* send button beside them, which waits for words.
|
||||
*/
|
||||
export const TooltipsInVoiceRecording: Story = {
|
||||
args: { tooltipsOpen: true, contents: BRIEF },
|
||||
play: async ({ canvasElement }): Promise<void> =>
|
||||
startVoiceInput(canvasElement),
|
||||
};
|
||||
|
||||
119
frontend/src/pages/AIAssistantPage/stories/__story_mockdata__/aiAssistant.play.ts
generated
Normal file
119
frontend/src/pages/AIAssistantPage/stories/__story_mockdata__/aiAssistant.play.ts
generated
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
interface PlayContext {
|
||||
canvasElement: HTMLElement;
|
||||
}
|
||||
|
||||
/** The thread list resolves before the thread does, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/** How long what a click opened has to stay on screen to count as open. */
|
||||
const HOLD_MS = 1_000;
|
||||
|
||||
/**
|
||||
* Click something, and keep clicking until what it opens is on screen. The
|
||||
* message list remounts its items while it measures a freshly loaded thread, so
|
||||
* a single click can land on a row that is about to be replaced, taking the
|
||||
* state it just set with it. The replacement can land after that state has
|
||||
* rendered, so it has to still be there `HOLD_MS` later.
|
||||
*/
|
||||
const clickUntil = async (
|
||||
find: () => Promise<HTMLElement>,
|
||||
opens: RegExp,
|
||||
): Promise<void> => {
|
||||
await waitFor(async () => {
|
||||
await userEvent.click(await find());
|
||||
await screen.findByText(opens, undefined, { timeout: 1_000 });
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, HOLD_MS);
|
||||
});
|
||||
screen.getByText(opens);
|
||||
}, untilLoaded);
|
||||
};
|
||||
|
||||
export const expandActivity = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await clickUntil(
|
||||
() => canvas.findByText(/worked through/i, undefined, untilLoaded),
|
||||
/compared checkout p99/i,
|
||||
);
|
||||
await clickUntil(
|
||||
() => canvas.findByText(/compared checkout p99/i, undefined, untilLoaded),
|
||||
/^Output$/,
|
||||
);
|
||||
};
|
||||
|
||||
/** Opens the approval card's diff dialog. */
|
||||
export const openApprovalDiff = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await clickUntil(
|
||||
() =>
|
||||
within(canvasElement).findByLabelText(
|
||||
/expand diff/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
/approval diff/i,
|
||||
);
|
||||
};
|
||||
|
||||
export const openNegativeFeedback = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await clickUntil(async () => {
|
||||
// Every assistant message carries the bar; only the last one shows it
|
||||
// without a hover.
|
||||
const bars = await within(canvasElement).findAllByLabelText(
|
||||
/bad response/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
return bars[bars.length - 1];
|
||||
}, /what went wrong/i);
|
||||
};
|
||||
|
||||
export const openConversationActions = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await clickUntil(async () => {
|
||||
const [actions] = await within(canvasElement).findAllByLabelText(
|
||||
/conversation actions/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
return actions;
|
||||
}, /copy link/i);
|
||||
};
|
||||
|
||||
export const openContextPicker = async ({
|
||||
canvasElement,
|
||||
}: PlayContext): Promise<void> => {
|
||||
await clickUntil(
|
||||
() =>
|
||||
within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: /add context/i },
|
||||
untilLoaded,
|
||||
),
|
||||
/checkout overview/i,
|
||||
);
|
||||
};
|
||||
|
||||
export const copyConversationLink = async (
|
||||
context: PlayContext,
|
||||
): Promise<void> => {
|
||||
await openConversationActions(context);
|
||||
await userEvent.click(await screen.findByText(/copy link/i));
|
||||
await screen.findByText(/conversation link copied to clipboard/i);
|
||||
};
|
||||
@@ -116,10 +116,6 @@ Storybook fills the seams with:
|
||||
because elapsed-time code reads it — `lodash.debounce` compares two readings
|
||||
to decide its trailing call is due, and a frozen one leaves every debounced
|
||||
input in the app filtering nothing. `?storyClock=live` opts out.
|
||||
- A speech recognizer in place of the browser's, also in
|
||||
`.storybook/preview-head.html`, because Storybook has no microphone. It
|
||||
starts listening on `start()` and hears only what a `play` passes to
|
||||
`webkitSpeechRecognition.listening.hear(text)`.
|
||||
- Theme from the toolbar (dark/light). `applyThemeBodyClass` puts `<body>` in the
|
||||
state the app gets from `index.html` plus `AppLayout`: `data-theme="default"`
|
||||
(every `@signozhq/design-tokens` semantic token is scoped to it, and without it
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import type { GlobalMockArgs } from '../globals';
|
||||
|
||||
@@ -72,6 +72,19 @@ export const NozAwaitingYou: Story = {
|
||||
args: { noz: true, nozAwaiting: 2, support: 'pylon' },
|
||||
};
|
||||
|
||||
/** The Noz chat opened from Ask Noz, floating above the strip; its header drags it. */
|
||||
export const NozPanel: Story = {
|
||||
args: { noz: true, support: 'pylon' },
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
within(
|
||||
await screen.findByTestId('bottom-strip-ask-noz', undefined, untilLoaded),
|
||||
).getByRole('button'),
|
||||
);
|
||||
await screen.findByRole('button', { name: 'Close panel' });
|
||||
},
|
||||
};
|
||||
|
||||
/** Every tooltip the strip carries, held open. */
|
||||
export const Tooltips: Story = {
|
||||
args: { noz: true, support: 'pylon', tooltipsOpen: true },
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AI_API_PATH } from 'api/AIAPIInstance';
|
||||
import type { ThreadListResponse } from 'api/ai-assistant/chat';
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import type { ConversationStreamState } from 'container/AIAssistant/types';
|
||||
import { chipsResponse } from 'pages/AIAssistantPage/stories/__story_mockdata__/aiAssistant';
|
||||
|
||||
import { countControl, toggleControl } from '../controls/controls';
|
||||
import { defineStoryMocks } from '../controls/defineStoryMocks';
|
||||
@@ -57,6 +58,10 @@ export const nozMocks = defineStoryMocks({
|
||||
`${nozGlobalConfigResponse.data.ai_assistant_url}${AI_API_PATH}/threads`,
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(noThreads)),
|
||||
),
|
||||
rest.get(
|
||||
`${nozGlobalConfigResponse.data.ai_assistant_url}${AI_API_PATH}/empty-state/chips`,
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(chipsResponse())),
|
||||
),
|
||||
]
|
||||
: [],
|
||||
effect: ({ nozAwaiting }) => {
|
||||
@@ -72,6 +77,9 @@ export const nozMocks = defineStoryMocks({
|
||||
for (let index = 0; index < nozAwaiting; index += 1) {
|
||||
state.streams[`${AWAITING_PREFIX}${index}`] = awaitingStream();
|
||||
}
|
||||
|
||||
// Persisted, so a panel one story opened would hide Ask Noz in the next.
|
||||
state.isDrawerOpen = false;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user