From 2882e362225c0982171b5e20d5d02cc0f0507119 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 4 Mar 2026 01:04:04 +0000 Subject: [PATCH] fix(site): move chat input outside flex-col-reverse scroller (#22585) --- site/src/pages/AgentsPage/AgentChatInput.tsx | 10 - .../pages/AgentsPage/AgentDetail.stories.tsx | 92 +++++++++ site/src/pages/AgentsPage/AgentDetail.tsx | 188 +++++++----------- 3 files changed, 164 insertions(+), 126 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatInput.tsx b/site/src/pages/AgentsPage/AgentChatInput.tsx index 6f867dbff5..5718c9c519 100644 --- a/site/src/pages/AgentsPage/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/AgentChatInput.tsx @@ -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( isEditingHistoryMessage = false, onCancelHistoryEdit, contextUsage, - sticky = false, }) => { const internalRef = useRef(null); @@ -467,12 +463,6 @@ export const AgentChatInput = memo( ); - if (sticky) { - return ( -
{content}
- ); - } - return content; }, ); diff --git a/site/src/pages/AgentsPage/AgentDetail.stories.tsx b/site/src/pages/AgentsPage/AgentDetail.stories.tsx index 08964e6b33..4d78869e7d 100644 --- a/site/src/pages/AgentsPage/AgentDetail.stories.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.stories.tsx @@ -201,6 +201,98 @@ type Story = StoryObj; // 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 = {}; diff --git a/site/src/pages/AgentsPage/AgentDetail.tsx b/site/src/pages/AgentsPage/AgentDetail.tsx index 20417b2ada..87d5724509 100644 --- a/site/src/pages/AgentsPage/AgentDetail.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.tsx @@ -298,54 +298,15 @@ const AgentDetailInput: FC = ({ modelSelectorPlaceholder={modelSelectorPlaceholder} inputStatusText={inputStatusText} modelCatalogStatusMessage={modelCatalogStatusMessage} - sticky /> ); }; -interface AgentDetailConversationProps { - store: ChatStoreHandle; - chatID: string; - persistedErrorReason: string | undefined; - compressionThreshold: number | undefined; - onDeleteQueuedMessage: (id: number) => Promise; - onPromoteQueuedMessage: (id: number) => Promise; +function useConversationEditingState(deps: { onSend: (message: string, editedMessageID?: number) => Promise; - 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 = ({ - store, - chatID, - persistedErrorReason, - compressionThreshold, - onDeleteQueuedMessage, - onPromoteQueuedMessage, - onSend, - onInterrupt, - isInputDisabled, - isSendPending, - isInterruptPending, - hasModelOptions, - selectedModel, - onModelChange, - modelOptions, - modelSelectorPlaceholder, - inputStatusText, - modelCatalogStatusMessage, - savingMessageId, -}) => { + onDeleteQueuedMessage: (id: number) => Promise; +}) { + const { onSend, onDeleteQueuedMessage } = deps; const inputValueRef = useRef(""); const chatInputRef = useRef(null); const [editorInitialValue, setEditorInitialValue] = useState(""); @@ -437,47 +398,19 @@ const AgentDetailConversation: FC = ({ [editingMessageId, editingQueuedMessageID, onDeleteQueuedMessage, onSend], ); - return ( - <> - - { - 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} /> -
+
@@ -948,22 +886,23 @@ const AgentDetail: FC = () => {
- {}} - initialValue="" - isDisabled={isInputDisabled} - isLoading={false} - selectedModel={selectedModel} - onModelChange={setSelectedModel} - modelOptions={modelOptions} - modelSelectorPlaceholder={modelSelectorPlaceholder} - hasModelOptions={hasModelOptions} - inputStatusText={inputStatusText} - modelCatalogStatusMessage={modelCatalogStatusMessage} - sticky - />
+
+ {}} + initialValue="" + isDisabled={isInputDisabled} + isLoading={false} + selectedModel={selectedModel} + onModelChange={setSelectedModel} + modelOptions={modelOptions} + modelSelectorPlaceholder={modelSelectorPlaceholder} + hasModelOptions={hasModelOptions} + inputStatusText={inputStatusText} + modelCatalogStatusMessage={modelCatalogStatusMessage} + /> +
); } @@ -1053,34 +992,51 @@ const AgentDetail: FC = () => {
-
+
+ { + editing.inputValueRef.current = content; + }} + editingQueuedMessageID={editing.editingQueuedMessageID} + onStartQueueEdit={editing.handleStartQueueEdit} + onCancelQueueEdit={editing.handleCancelQueueEdit} + isEditingHistoryMessage={editing.editingMessageId !== null} + onCancelHistoryEdit={editing.handleCancelHistoryEdit} + /> +