Compare commits

...

5 Commits

Author SHA1 Message Date
Gaurav Tewari
f8ef6d478a feat(noz): restyle the chat input to the new design
Send and stop stay SigNoz Buttons, sized and coloured through the Button's CSS variables.
2026-11-12 23:06:48 +05:30
Gaurav Tewari
4770657167 refactor(noz): drop unused chat input styles 2026-11-12 23:06:48 +05:30
Gaurav Tewari
b5a8e7fb0a Merge branch 'main' into feat/noz-floating-panel 2026-11-12 21:26:01 +05:30
Gaurav Tewari
5a9bbc0e64 feat(noz): resize the Noz panel from its edges, down to 380 × 500 2026-11-12 19:50:16 +05:30
Gaurav Tewari
09784eec6c feat(noz): float the Noz panel and let its header drag it
The panel reuses FloatingPanel, so resizing moves from the left-edge
handle to react-rnd's edges.
2026-11-12 19:35:05 +05:30
9 changed files with 206 additions and 231 deletions

View File

@@ -1,37 +1,5 @@
.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) {
box-shadow: var(--shadow-dialog);
}
.header {

View File

@@ -1,4 +1,4 @@
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';
@@ -7,6 +7,8 @@ import { Tooltip } from '@signozhq/ui/tooltip';
import ROUTES from 'constants/routes';
import { History, Maximize2, 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>
@@ -207,7 +214,7 @@ export default function AIAssistantPanel(): JSX.Element | null {
<ConversationView conversationId={activeConversationId} />
)
)}
</div>
</FloatingPanel>
</VariantContext.Provider>
);
}

View File

@@ -34,7 +34,7 @@
border-top: 1px solid var(--l1-border);
&.compact {
padding: 8px;
padding: 0;
}
}

View File

@@ -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,72 +99,53 @@
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%);
}
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;
// Send rests grey and turns primary on hover.
--button-primary-background-color: var(--l3-background);
--button-primary-hover-background-color: var(--primary-background);
--button-primary-solid-foreground: var(--l2-foreground);
.attachBtn {
color: var(--l3-foreground);
}
.contextBtn {
flex-shrink: 0;
gap: 6px;
border-style: dashed !important;
padding-inline: 12px !important;
[data-color='primary']:hover:not([aria-disabled='true']) {
--button-primary-solid-foreground: var(--primary-foreground);
}
}
.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;
@include scrollbar(0.2rem);
&::placeholder {
color: var(--l3-foreground);
color: var(--l2-foreground);
opacity: 0.6;
}
@@ -170,7 +159,7 @@
display: inline-flex;
align-items: center;
gap: 6px;
width: 100%;
margin: 0 10px;
padding: 6px 8px;
font-size: 11px;
font-weight: 500;
@@ -180,24 +169,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);
@@ -342,9 +316,8 @@
.micRecording {
display: flex;
align-items: center;
background: var(--l2-background);
background: var(--l3-background);
border-radius: 999px;
padding: 2px;
gap: 0;
flex-shrink: 0;
overflow: hidden;

View File

@@ -46,12 +46,13 @@ import {
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 {
ArrowUp,
AtSign,
Mic,
Plus,
Search,
Send,
Square,
TriangleAlert,
X,
@@ -249,6 +250,7 @@ export default function ChatInput({
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<
@@ -900,7 +902,10 @@ 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}
>
{pendingFiles.length > 0 && (
<div className={styles.attachments}>
{pendingFiles.map((f) => (
@@ -983,7 +988,7 @@ 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);
@@ -995,7 +1000,7 @@ export default function ChatInput({
onKeyDown={handleKeyDown}
disabled={disabled}
maxLength={MAX_INPUT_LENGTH}
rows={3}
rows={2}
/>
</div>
{showTextWarning && (
@@ -1009,7 +1014,69 @@ export default function ChatInput({
)}
<div className={styles.footer}>
<div className={styles.leftActions}>
<div className={styles.actions}>
{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="sm"
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="sm"
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="sm"
icon
onClick={(): void => startVoiceInput(VoiceInputSource.Button)}
disabled={disabled}
aria-label="Start voice input"
>
<Mic size={14} />
</Button>
</Tooltip>
))}
<Popover
open={isContextPickerOpen}
onOpenChange={(open): void => {
@@ -1020,22 +1087,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 +1234,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 +1253,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}
disabled={disabled || (!text.trim() && pendingFiles.length === 0)}
aria-label="Send message"
>
<Send size={14} />
<ArrowUp size={14} />
</Button>
</Tooltip>
)}

View File

@@ -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', () => {

View File

@@ -58,7 +58,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', () => {

View File

@@ -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 },

View File

@@ -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;
});
},
});