mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(site): move chat input outside flex-col-reverse scroller (#22585)
This commit is contained in:
@@ -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 = {};
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user