fix(site): move chat input outside flex-col-reverse scroller (#22585)

This commit is contained in:
Danielle Maywood
2026-03-04 01:04:04 +00:00
committed by GitHub
parent 13411c8a8a
commit 2882e36222
3 changed files with 164 additions and 126 deletions
@@ -82,9 +82,6 @@ interface AgentChatInputProps {
// Pass `null` to render fallback values (e.g. when limit is unknown).
// Omit entirely to hide the indicator.
contextUsage?: AgentContextUsage | null;
// When true the entire input sticks to the bottom of the scroll
// container (used in the detail page).
sticky?: boolean;
}
const hasFiniteTokenValue = (value: number | undefined): value is number =>
@@ -239,7 +236,6 @@ export const AgentChatInput = memo<AgentChatInputProps>(
isEditingHistoryMessage = false,
onCancelHistoryEdit,
contextUsage,
sticky = false,
}) => {
const internalRef = useRef<ChatMessageInputRef>(null);
@@ -467,12 +463,6 @@ export const AgentChatInput = memo<AgentChatInputProps>(
</div>
);
if (sticky) {
return (
<div className="sticky bottom-0 z-50 bg-surface-primary">{content}</div>
);
}
return content;
},
);
@@ -201,6 +201,98 @@ type Story = StoryObj<typeof AgentDetailLayout>;
// Stories
// ---------------------------------------------------------------------------
/** Multi-turn conversation with message history and the chat input visible. */
export const WithMessageHistory: Story = {
parameters: {
queries: buildQueries(
{
chat: {
id: CHAT_ID,
...baseChatFields,
title: "Help me refactor this module",
status: "completed",
},
messages: [
{
id: 1,
chat_id: CHAT_ID,
created_at: "2026-02-18T00:01:00.000Z",
role: "user",
content: [
{
type: "text",
text: "Can you help me refactor the authentication module? It's gotten pretty messy.",
},
],
},
{
id: 2,
chat_id: CHAT_ID,
created_at: "2026-02-18T00:01:30.000Z",
role: "assistant",
content: [
{
type: "text",
text: "Sure! I'll start by looking at the current structure. The main issues I can see are:\n\n1. **Mixed concerns** — token validation and session management are interleaved\n2. **No error hierarchy** — all auth errors are treated the same\n3. **Duplicated middleware** — the same checks appear in three places\n\nLet me propose a cleaner separation.",
},
],
},
{
id: 3,
chat_id: CHAT_ID,
created_at: "2026-02-18T00:02:00.000Z",
role: "user",
content: [
{
type: "text",
text: "That sounds right. Can you start with the token validation? I want to make sure we handle JWT expiration properly.",
},
],
},
{
id: 4,
chat_id: CHAT_ID,
created_at: "2026-02-18T00:02:30.000Z",
role: "assistant",
content: [
{
type: "text",
text: "Here's the refactored token validation:\n\n```go\nfunc ValidateToken(ctx context.Context, token string) (*Claims, error) {\n claims, err := parseToken(token)\n if err != nil {\n return nil, ErrInvalidToken\n }\n if claims.ExpiresAt.Before(time.Now()) {\n return nil, ErrTokenExpired\n }\n return claims, nil\n}\n```\n\nKey changes:\n- Separated parsing from expiration checking\n- Added typed errors (`ErrInvalidToken`, `ErrTokenExpired`) so callers can distinguish between a malformed token and an expired one\n- The context parameter allows us to add tracing later",
},
],
},
{
id: 5,
chat_id: CHAT_ID,
created_at: "2026-02-18T00:03:00.000Z",
role: "user",
content: [
{
type: "text",
text: "Looks good. Now what about the middleware deduplication?",
},
],
},
{
id: 6,
chat_id: CHAT_ID,
created_at: "2026-02-18T00:03:30.000Z",
role: "assistant",
content: [
{
type: "text",
text: "I've consolidated the three middleware instances into a single composable chain:\n\n```go\nfunc AuthMiddleware(opts ...AuthOption) func(http.Handler) http.Handler {\n cfg := defaultAuthConfig()\n for _, opt := range opts {\n opt(&cfg)\n }\n return func(next http.Handler) http.Handler {\n return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n claims, err := ValidateToken(r.Context(), extractToken(r))\n if err != nil {\n cfg.ErrorHandler(w, r, err)\n return\n }\n ctx := context.WithValue(r.Context(), claimsKey, claims)\n next.ServeHTTP(w, r.WithContext(ctx))\n })\n }\n}\n```\n\nThe functional options pattern lets each route customize behavior (e.g. optional auth, different error responses) without duplicating the core logic.",
},
],
},
],
queued_messages: [],
},
{ diffUrl: undefined },
),
},
};
/** Skeleton placeholder when no query data is available yet. */
export const Loading: Story = {};
+72 -116
View File
@@ -298,54 +298,15 @@ const AgentDetailInput: FC<AgentDetailInputProps> = ({
modelSelectorPlaceholder={modelSelectorPlaceholder}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
sticky
/>
);
};
interface AgentDetailConversationProps {
store: ChatStoreHandle;
chatID: string;
persistedErrorReason: string | undefined;
compressionThreshold: number | undefined;
onDeleteQueuedMessage: (id: number) => Promise<void>;
onPromoteQueuedMessage: (id: number) => Promise<void>;
function useConversationEditingState(deps: {
onSend: (message: string, editedMessageID?: number) => Promise<void>;
onInterrupt: () => void;
isInputDisabled: boolean;
isSendPending: boolean;
isInterruptPending: boolean;
hasModelOptions: boolean;
selectedModel: string;
onModelChange: (modelID: string) => void;
modelOptions: readonly ModelSelectorOption[];
modelSelectorPlaceholder: string;
inputStatusText: string | null;
modelCatalogStatusMessage: string | null;
savingMessageId?: number | null;
}
const AgentDetailConversation: FC<AgentDetailConversationProps> = ({
store,
chatID,
persistedErrorReason,
compressionThreshold,
onDeleteQueuedMessage,
onPromoteQueuedMessage,
onSend,
onInterrupt,
isInputDisabled,
isSendPending,
isInterruptPending,
hasModelOptions,
selectedModel,
onModelChange,
modelOptions,
modelSelectorPlaceholder,
inputStatusText,
modelCatalogStatusMessage,
savingMessageId,
}) => {
onDeleteQueuedMessage: (id: number) => Promise<void>;
}) {
const { onSend, onDeleteQueuedMessage } = deps;
const inputValueRef = useRef("");
const chatInputRef = useRef<ChatMessageInputRef>(null);
const [editorInitialValue, setEditorInitialValue] = useState("");
@@ -437,47 +398,19 @@ const AgentDetailConversation: FC<AgentDetailConversationProps> = ({
[editingMessageId, editingQueuedMessageID, onDeleteQueuedMessage, onSend],
);
return (
<>
<AgentDetailTimeline
store={store}
chatID={chatID}
persistedErrorReason={persistedErrorReason}
onEditUserMessage={handleEditUserMessage}
editingMessageId={editingMessageId}
savingMessageId={savingMessageId}
/>
<AgentDetailInput
store={store}
compressionThreshold={compressionThreshold}
onSend={handleSendFromInput}
onDeleteQueuedMessage={onDeleteQueuedMessage}
onPromoteQueuedMessage={onPromoteQueuedMessage}
onInterrupt={onInterrupt}
isInputDisabled={isInputDisabled}
isSendPending={isSendPending}
isInterruptPending={isInterruptPending}
hasModelOptions={hasModelOptions}
selectedModel={selectedModel}
onModelChange={onModelChange}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
inputRef={chatInputRef}
initialValue={editorInitialValue}
onContentChange={(content) => {
inputValueRef.current = content;
}}
editingQueuedMessageID={editingQueuedMessageID}
onStartQueueEdit={handleStartQueueEdit}
onCancelQueueEdit={handleCancelQueueEdit}
isEditingHistoryMessage={editingMessageId !== null}
onCancelHistoryEdit={handleCancelHistoryEdit}
/>
</>
);
};
return {
inputValueRef,
chatInputRef,
editorInitialValue,
editingMessageId,
handleEditUserMessage,
handleCancelHistoryEdit,
editingQueuedMessageID,
handleStartQueueEdit,
handleCancelQueueEdit,
handleSendFromInput,
};
}
const AgentDetail: FC = () => {
const navigate = useNavigate();
@@ -803,6 +736,11 @@ const AgentDetail: FC = () => {
[promoteQueuedMutation, store],
);
const editing = useConversationEditingState({
onSend: handleSend,
onDeleteQueuedMessage: handleDeleteQueuedMessage,
});
const chatTitle = chatQuery.data?.chat?.title;
// Update the browser tab title when navigating to / between agents.
@@ -920,7 +858,7 @@ const AgentDetail: FC = () => {
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
<div className="flex h-full flex-col-reverse overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col-reverse overflow-hidden">
<div className="px-4">
<div className="mx-auto w-full max-w-3xl py-6">
<div className="flex flex-col gap-3">
@@ -948,22 +886,23 @@ const AgentDetail: FC = () => {
</div>
</div>
</div>
<AgentChatInput
onSend={() => {}}
initialValue=""
isDisabled={isInputDisabled}
isLoading={false}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
hasModelOptions={hasModelOptions}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
sticky
/>
</div>
</div>
<div className="shrink-0 px-4">
<AgentChatInput
onSend={() => {}}
initialValue=""
isDisabled={isInputDisabled}
isLoading={false}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
hasModelOptions={hasModelOptions}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
/>
</div>
</div>
);
}
@@ -1053,34 +992,51 @@ const AgentDetail: FC = () => {
</div>
<div
ref={scrollContainerRef}
className="flex h-full flex-col-reverse overflow-y-auto [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]"
className="flex min-h-0 flex-1 flex-col-reverse overflow-y-auto [scrollbar-gutter:stable] [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]"
>
<div className="px-4">
<AgentDetailConversation
<AgentDetailTimeline
store={store}
chatID={agentId}
persistedErrorReason={
chatErrorReasons[agentId] || chatRecord?.last_error || undefined
}
compressionThreshold={compressionThreshold}
onDeleteQueuedMessage={handleDeleteQueuedMessage}
onPromoteQueuedMessage={handlePromoteQueuedMessage}
onSend={handleSend}
onInterrupt={handleInterrupt}
isInputDisabled={isInputDisabled}
isSendPending={isSubmissionPending}
isInterruptPending={interruptMutation.isPending}
hasModelOptions={hasModelOptions}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
onEditUserMessage={editing.handleEditUserMessage}
editingMessageId={editing.editingMessageId}
savingMessageId={pendingEditMessageId}
/>
</div>
</div>
<div className="shrink-0 overflow-y-auto px-4 [scrollbar-gutter:stable] [scrollbar-width:thin]">
<AgentDetailInput
store={store}
compressionThreshold={compressionThreshold}
onSend={editing.handleSendFromInput}
onDeleteQueuedMessage={handleDeleteQueuedMessage}
onPromoteQueuedMessage={handlePromoteQueuedMessage}
onInterrupt={handleInterrupt}
isInputDisabled={isInputDisabled}
isSendPending={isSubmissionPending}
isInterruptPending={interruptMutation.isPending}
hasModelOptions={hasModelOptions}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
inputRef={editing.chatInputRef}
initialValue={editing.editorInitialValue}
onContentChange={(content) => {
editing.inputValueRef.current = content;
}}
editingQueuedMessageID={editing.editingQueuedMessageID}
onStartQueueEdit={editing.handleStartQueueEdit}
onCancelQueueEdit={editing.handleCancelQueueEdit}
isEditingHistoryMessage={editing.editingMessageId !== null}
onCancelHistoryEdit={editing.handleCancelHistoryEdit}
/>
</div>
</div>
<DiffRightPanel isOpen={shouldShowDiffPanel}>
<FilesChangedPanel chatId={agentId} />