Fix a few bugs

This commit is contained in:
Kyle Carberry
2026-02-24 11:32:28 -05:00
parent 7dd51ef3e5
commit 62f8974cb8
10 changed files with 3784 additions and 4896 deletions
+100 -25
View File
@@ -30,6 +30,8 @@ import (
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/pubsub"
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
@@ -101,6 +103,7 @@ type Processor struct {
workspaceCreator WorkspaceCreator
modelResolver ModelResolver
streamManager *StreamManager
pubsub pubsub.Pubsub
subagentService *SubagentService
providerKeys ProviderAPIKeys
providerKeysFn ProviderAPIKeysResolver
@@ -323,6 +326,13 @@ func WithStreamManager(manager *StreamManager) Option {
}
}
// WithPubsub sets the pubsub used to broadcast chat lifecycle events.
func WithPubsub(ps pubsub.Pubsub) Option {
return func(p *Processor) {
p.pubsub = ps
}
}
// WithProviderAPIKeys sets fallback provider API keys used for model calls.
func WithProviderAPIKeys(keys ProviderAPIKeys) Option {
return func(p *Processor) {
@@ -463,6 +473,65 @@ func (p *Processor) publishStatus(chatID uuid.UUID, status database.ChatStatus)
})
}
// publishChatPubsubEvent broadcasts a chat lifecycle event via PostgreSQL
// pubsub so that all replicas can push updates to watching clients.
func (p *Processor) publishChatPubsubEvent(chat database.Chat, kind coderdpubsub.ChatEventKind) {
if p.pubsub == nil {
return
}
event := coderdpubsub.ChatEvent{
Kind: kind,
Chat: convertChatForPubsub(chat),
}
payload, err := json.Marshal(event)
if err != nil {
p.logger.Error(context.Background(), "failed to marshal chat pubsub event",
slog.F("chat_id", chat.ID),
slog.Error(err),
)
return
}
if err := p.pubsub.Publish(coderdpubsub.ChatEventChannel(chat.OwnerID), payload); err != nil {
p.logger.Error(context.Background(), "failed to publish chat pubsub event",
slog.F("chat_id", chat.ID),
slog.F("kind", kind),
slog.Error(err),
)
}
}
// convertChatForPubsub converts a database.Chat to codersdk.Chat for pubsub events.
// This is a lightweight conversion — we don't include diff status since
// the watch subscriber can fetch that separately if needed.
func convertChatForPubsub(c database.Chat) codersdk.Chat {
chat := codersdk.Chat{
ID: c.ID,
OwnerID: c.OwnerID,
Title: c.Title,
Status: codersdk.ChatStatus(c.Status),
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
if c.ParentChatID.Valid {
parentChatID := c.ParentChatID.UUID
chat.ParentChatID = &parentChatID
}
if c.RootChatID.Valid {
rootChatID := c.RootChatID.UUID
chat.RootChatID = &rootChatID
} else if !c.ParentChatID.Valid {
rootChatID := c.ID
chat.RootChatID = &rootChatID
}
if c.WorkspaceID.Valid {
chat.WorkspaceID = &c.WorkspaceID.UUID
}
if c.WorkspaceAgentID.Valid {
chat.WorkspaceAgentID = &c.WorkspaceAgentID.UUID
}
return chat
}
func (p *Processor) publishError(chatID uuid.UUID, message string) {
message = strings.TrimSpace(message)
if message == "" {
@@ -688,6 +757,8 @@ func (p *Processor) processChat(ctx context.Context, chat database.Chat) {
}
p.publishStatus(chat.ID, status)
chat.Status = status
p.publishChatPubsubEvent(chat, coderdpubsub.ChatEventKindStatusChange)
if p.subagentService != nil {
p.subagentService.publishChildStatus(chat, status)
}
@@ -767,9 +838,8 @@ func (p *Processor) runChat(
}
type chatContextCompressionConfig struct {
ContextLimit int64
ThresholdPercent int32
ThresholdExplicit bool
ContextLimit int64
ThresholdPercent int32
}
type chatContextUsageSnapshot struct {
@@ -864,17 +934,6 @@ func (p *Processor) resolveChatContextCompressionConfig(
if chatConfig.ContextLimit > 0 {
config.ContextLimit = chatConfig.ContextLimit
}
if chatConfig.ContextCompressionThreshold != nil {
config.ThresholdPercent = normalizeContextCompressionThreshold(
*chatConfig.ContextCompressionThreshold,
)
config.ThresholdExplicit = true
}
if config.ContextLimit > 0 && config.ThresholdExplicit {
return config, nil
}
modelName := strings.TrimSpace(chatConfig.Model)
if modelName == "" {
modelName = defaultChatModel
@@ -903,11 +962,9 @@ func (p *Processor) resolveChatContextCompressionConfig(
if config.ContextLimit <= 0 {
config.ContextLimit = modelConfig.ContextLimit
}
if !config.ThresholdExplicit {
config.ThresholdPercent = normalizeContextCompressionThreshold(
modelConfig.CompressionThreshold,
)
}
config.ThresholdPercent = normalizeContextCompressionThreshold(
modelConfig.CompressionThreshold,
)
return config, nil
}
@@ -1198,6 +1255,14 @@ func (p *Processor) runChatWithAgent(
getWorkspaceConn,
)
// Resolve the model config's context_limit so we can use it as a
// fallback when the LLM provider doesn't include context_limit in
// its response metadata (which is the common case).
var modelConfigContextLimit int64
if compressionConfig, err := p.resolveChatContextCompressionConfig(ctx, chat); err == nil {
modelConfigContextLimit = compressionConfig.ContextLimit
}
persistAssistant := func(
content []fantasy.Content,
usage fantasy.Usage,
@@ -1375,10 +1440,18 @@ func (p *Processor) runChatWithAgent(
toolResults := append([]ToolResultBlock(nil), stepToolResults...)
stepStateMu.Unlock()
contextLimit := extractContextLimit(stepResult.ProviderMetadata)
if !contextLimit.Valid && modelConfigContextLimit > 0 {
contextLimit = sql.NullInt64{
Int64: modelConfigContextLimit,
Valid: true,
}
}
if err := persistAssistant(
stepAssistantContent(stepResult.Content, toolResults),
stepResult.Usage,
extractContextLimit(stepResult.ProviderMetadata),
contextLimit,
); err != nil {
return err
}
@@ -1695,7 +1768,10 @@ func (p *Processor) maybeGenerateChatTitle(
slog.F("chat_id", chat.ID),
slog.Error(err),
)
return
}
chat.Title = title
p.publishChatPubsubEvent(chat, coderdpubsub.ChatEventKindTitleChange)
}
func (p *Processor) generateChatTitle(ctx context.Context, input string) (string, error) {
@@ -2820,11 +2896,10 @@ func intValue(value any) (int, bool) {
}
type chatModelConfig struct {
Provider string `json:"provider,omitempty"`
Model string `json:"model"`
WorkspaceMode codersdk.ChatWorkspaceMode `json:"workspace_mode,omitempty"`
ContextLimit int64 `json:"context_limit,omitempty"`
ContextCompressionThreshold *int32 `json:"context_compression_threshold,omitempty"`
Provider string `json:"provider,omitempty"`
Model string `json:"model"`
WorkspaceMode codersdk.ChatWorkspaceMode `json:"workspace_mode,omitempty"`
ContextLimit int64 `json:"context_limit,omitempty"`
}
func workspaceModeFromChat(chat database.Chat) codersdk.ChatWorkspaceMode {
+142 -54
View File
@@ -1,7 +1,6 @@
package coderd
import (
"bytes"
"charm.land/fantasy"
"context"
"database/sql"
@@ -29,6 +28,7 @@ import (
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpapi/httperror"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/pubsub"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/tracing"
@@ -118,6 +118,69 @@ type chatWorkspaceRequestMetadata struct {
RequestID string `json:"request_id,omitempty"`
}
// @Summary Watch chat list updates
// @ID watch-chats
// @Security CoderSessionToken
// @Produce json
// @Tags Chats
// @Success 200 {object} codersdk.ServerSentEvent
// @Router /chats/watch [get]
func (api *API) watchChats(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
sendEvent, senderClosed, err := httpapi.OneWayWebSocketEventSender(api.Logger)(rw, r)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to open chat watch stream.",
Detail: err.Error(),
})
return
}
defer func() {
<-senderClosed
}()
cancelSubscribe, err := api.Pubsub.SubscribeWithErr(pubsub.ChatEventChannel(apiKey.UserID),
pubsub.HandleChatEvent(
func(ctx context.Context, payload pubsub.ChatEvent, err error) {
if err != nil {
api.Logger.Error(ctx, "chat event subscription error", slog.Error(err))
return
}
_ = sendEvent(codersdk.ServerSentEvent{
Type: codersdk.ServerSentEventTypeData,
Data: payload,
})
},
))
if err != nil {
_ = sendEvent(codersdk.ServerSentEvent{
Type: codersdk.ServerSentEventTypeError,
Data: codersdk.Response{
Message: "Internal error subscribing to chat events.",
Detail: err.Error(),
},
})
return
}
defer cancelSubscribe()
// Send initial ping to signal the connection is ready.
_ = sendEvent(codersdk.ServerSentEvent{
Type: codersdk.ServerSentEventTypePing,
})
for {
select {
case <-ctx.Done():
return
case <-senderClosed:
return
}
}
}
// @Summary List chats
// @ID list-chats
// @Security CoderSessionToken
@@ -412,7 +475,6 @@ func (api *API) createChat(rw http.ResponseWriter, r *http.Request) {
modelConfig, modelConfigErr = api.applyChatModelCompressionConfig(
ctx,
modelConfig,
req.ContextCompressionThreshold,
)
if modelConfigErr != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
@@ -531,6 +593,8 @@ func (api *API) createChat(rw http.ResponseWriter, r *http.Request) {
chat = updatedChat
}
api.publishChatPubsubEvent(ctx, chat, pubsub.ChatEventKindCreated)
httpapi.Write(ctx, rw, http.StatusCreated, convertChat(chat, nil))
}
@@ -636,7 +700,7 @@ func (api *API) deleteChat(rw http.ResponseWriter, r *http.Request) {
}
// Check that the chat exists and user has access.
_, err := api.Database.GetChatByID(ctx, chatID)
chat, err := api.Database.GetChatByID(ctx, chatID)
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
@@ -649,7 +713,43 @@ func (api *API) deleteChat(rw http.ResponseWriter, r *http.Request) {
return
}
err = api.Database.DeleteChatByID(ctx, chatID)
// Delete the chat and all its descendants in a transaction.
// Child chats (sub-agent chats) reference their parent via
// parent_chat_id with ON DELETE SET NULL, so without explicit
// cleanup they would become orphaned root-level items.
err = api.Database.InTx(func(tx database.Store) error {
// Recursively collect all descendant chat IDs.
var descendantIDs []uuid.UUID
queue := []uuid.UUID{chatID}
for len(queue) > 0 {
parentID := queue[0]
queue = queue[1:]
children, err := tx.ListChildChatsByParentID(ctx, parentID)
if err != nil {
return xerrors.Errorf("list children of chat %s: %w", parentID, err)
}
for _, child := range children {
descendantIDs = append(descendantIDs, child.ID)
queue = append(queue, child.ID)
}
}
// Delete descendants first. The FK is ON DELETE SET NULL so
// order doesn't strictly matter, but deleting children before
// parents is cleaner.
for i := len(descendantIDs) - 1; i >= 0; i-- {
if err := tx.DeleteChatByID(ctx, descendantIDs[i]); err != nil {
return xerrors.Errorf("delete descendant chat %s: %w", descendantIDs[i], err)
}
}
// Delete the target chat itself.
if err := tx.DeleteChatByID(ctx, chatID); err != nil {
return xerrors.Errorf("delete chat: %w", err)
}
return nil
}, nil)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to delete chat.",
@@ -658,6 +758,8 @@ func (api *API) deleteChat(rw http.ResponseWriter, r *http.Request) {
return
}
api.publishChatPubsubEvent(ctx, chat, pubsub.ChatEventKindDeleted)
rw.WriteHeader(http.StatusNoContent)
}
@@ -706,39 +808,6 @@ func (api *API) createChatMessage(rw http.ResponseWriter, r *http.Request) {
return
}
if req.Role == "user" && req.ContextCompressionThreshold != nil {
updatedModelConfig, modelConfigErr := api.applyChatModelCompressionConfig(
ctx,
chat.ModelConfig,
req.ContextCompressionThreshold,
)
if modelConfigErr != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid model configuration.",
Detail: modelConfigErr.Error(),
})
return
}
if !bytes.Equal(chat.ModelConfig, updatedModelConfig) {
updatedChat, updateErr := api.Database.UpdateChatModelConfigByChatID(
ctx,
database.UpdateChatModelConfigByChatIDParams{
ID: chat.ID,
ModelConfig: updatedModelConfig,
},
)
if updateErr != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to update chat model configuration.",
Detail: updateErr.Error(),
})
return
}
chat = updatedChat
}
}
// For user messages, atomically decide whether to queue or send based on
// the chat status. We use a transaction with FOR UPDATE to prevent races
// between the user sending and the processor finishing.
@@ -877,6 +946,11 @@ func (api *API) createChatMessage(rw http.ResponseWriter, r *http.Request) {
publishFn()
}
if !response.Queued {
chat.Status = database.ChatStatusPending
api.publishChatPubsubEvent(ctx, chat, pubsub.ChatEventKindStatusChange)
}
// Handle local workspace agent launch (outside the tx).
if !response.Queued {
if api.chatLocalService != nil {
@@ -1324,6 +1398,8 @@ func (api *API) interruptChat(rw http.ResponseWriter, r *http.Request) {
})
}
api.publishChatPubsubEvent(ctx, chat, pubsub.ChatEventKindStatusChange)
httpapi.Write(ctx, rw, http.StatusOK, convertChat(chat, nil))
}
@@ -1733,6 +1809,32 @@ func (api *API) publishChatStatusEvent(chat database.Chat) {
})
}
// publishChatPubsubEvent broadcasts a chat lifecycle event via PostgreSQL
// LISTEN/NOTIFY so that all server replicas can push real-time updates to
// watching sidebar WebSocket connections.
func (api *API) publishChatPubsubEvent(ctx context.Context, chat database.Chat, kind pubsub.ChatEventKind) {
sdkChat := convertChat(chat, nil)
event := pubsub.ChatEvent{
Kind: kind,
Chat: sdkChat,
}
payload, err := json.Marshal(event)
if err != nil {
api.Logger.Error(ctx, "failed to marshal chat pubsub event",
slog.F("chat_id", chat.ID),
slog.Error(err),
)
return
}
if err := api.Pubsub.Publish(pubsub.ChatEventChannel(chat.OwnerID), payload); err != nil {
api.Logger.Error(ctx, "failed to publish chat pubsub event",
slog.F("chat_id", chat.ID),
slog.F("kind", kind),
slog.Error(err),
)
}
}
func (api *API) resolveChatDiffContents(
ctx context.Context,
chat database.Chat,
@@ -2782,15 +2884,11 @@ func parseCanonicalChatModelRef(modelRef string) (string, string, bool) {
func (api *API) applyChatModelCompressionConfig(
ctx context.Context,
modelConfigRaw json.RawMessage,
requestedThreshold *int32,
) (json.RawMessage, error) {
config := map[string]any{}
if len(modelConfigRaw) > 0 {
if err := json.Unmarshal(modelConfigRaw, &config); err != nil {
if requestedThreshold == nil {
return modelConfigRaw, nil
}
return nil, xerrors.Errorf("parse model config: %w", err)
return modelConfigRaw, nil
}
}
if config == nil {
@@ -2816,20 +2914,10 @@ func (api *API) applyChatModelCompressionConfig(
}
}
thresholdFallback := defaultChatContextCompressionThreshold
if existingThreshold, ok := chatCompressionThresholdFromModelConfig(config); ok {
thresholdFallback = existingThreshold
if _, ok := chatCompressionThresholdFromModelConfig(config); !ok {
config[chatContextCompressionThresholdModelConfigKey] = defaultChatContextCompressionThreshold
}
threshold, err := normalizeChatCompressionThreshold(
requestedThreshold,
thresholdFallback,
)
if err != nil {
return nil, err
}
config[chatContextCompressionThresholdModelConfigKey] = threshold
encoded, err := json.Marshal(config)
if err != nil {
return nil, xerrors.Errorf("encode model config: %w", err)
+2
View File
@@ -821,6 +821,7 @@ func New(options *Options) *API {
chatd.WithAgentConnector(api.agentProvider),
chatd.WithWorkspaceCreator(api.newChatWorkspaceCreator()),
chatd.WithStreamManager(api.chatStreamManager),
chatd.WithPubsub(options.Pubsub),
)
}
api.chatProcessor = options.ChatProcessor
@@ -1235,6 +1236,7 @@ func New(options *Options) *API {
r.Get("/", api.listChats)
r.Post("/", api.createChat)
r.Get("/models", api.listChatModels)
r.Get("/watch", api.watchChats)
r.Route("/providers", func(r chi.Router) {
r.Get("/", api.listChatProviders)
r.Post("/", api.createChatProvider)
+46
View File
@@ -0,0 +1,46 @@
package pubsub
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk"
)
func ChatEventChannel(ownerID uuid.UUID) string {
return fmt.Sprintf("chat:owner:%s", ownerID)
}
func HandleChatEvent(cb func(ctx context.Context, payload ChatEvent, err error)) func(ctx context.Context, message []byte, err error) {
return func(ctx context.Context, message []byte, err error) {
if err != nil {
cb(ctx, ChatEvent{}, xerrors.Errorf("chat event pubsub: %w", err))
return
}
var payload ChatEvent
if err := json.Unmarshal(message, &payload); err != nil {
cb(ctx, ChatEvent{}, xerrors.Errorf("unmarshal chat event"))
return
}
cb(ctx, payload, err)
}
}
type ChatEvent struct {
Kind ChatEventKind `json:"kind"`
Chat codersdk.Chat `json:"chat"`
}
type ChatEventKind string
const (
ChatEventKindStatusChange ChatEventKind = "status_change"
ChatEventKindTitleChange ChatEventKind = "title_change"
ChatEventKindCreated ChatEventKind = "created"
ChatEventKindDeleted ChatEventKind = "deleted"
)
-2
View File
@@ -143,7 +143,6 @@ type CreateChatRequest struct {
ParentChatID *uuid.UUID `json:"parent_chat_id,omitempty" format:"uuid"`
Model string `json:"model,omitempty"`
ModelConfig json.RawMessage `json:"model_config,omitempty"`
ContextCompressionThreshold *int32 `json:"context_compression_threshold,omitempty"`
}
// UpdateChatRequest is the request to update a chat.
@@ -157,7 +156,6 @@ type CreateChatMessageRequest struct {
Content json.RawMessage `json:"content,omitempty"`
ToolCallID *string `json:"tool_call_id,omitempty"`
Thinking *string `json:"thinking,omitempty"`
ContextCompressionThreshold *int32 `json:"context_compression_threshold,omitempty"`
}
// CreateChatMessageResponse is the response from adding a message to a chat.
+7
View File
@@ -146,6 +146,13 @@ export const watchChat = (
});
};
export const watchChats =
(): OneWayWebSocket<TypesGen.ServerSentEvent> => {
return new OneWayWebSocket({
apiRoute: "/api/v2/chats/watch",
});
};
export const watchAgentContainers = (
agentId: string,
): OneWayWebSocket<TypesGen.WorkspaceAgentListContainersResponse> => {
+3389 -4564
View File
File diff suppressed because it is too large Load Diff
+8 -28
View File
@@ -3,14 +3,13 @@ import {
type ModelSelectorOption,
} from "components/ai-elements";
import { Button } from "components/Button/Button";
import { Input } from "components/Input/Input";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { ArrowUpIcon, ListPlusIcon, Loader2Icon, Square } from "lucide-react";
import { memo, type ReactNode, useCallback, useState } from "react";
import { memo, type ReactNode, useCallback, useRef, useState } from "react";
import TextareaAutosize from "react-textarea-autosize";
import { formatProviderLabel } from "./modelOptions";
@@ -53,9 +52,7 @@ interface AgentChatInputProps {
// Extra controls rendered in the left action area (e.g. workspace
// selector on the create page).
leftActions?: ReactNode;
// Optional context compression threshold percentage input (0-100).
contextCompressionThreshold?: string;
onContextCompressionThresholdChange?: (value: string) => void;
// Optional context-usage summary shown to the left of the send button.
// Pass `null` to render fallback values (e.g. when limit is unknown).
// Omit entirely to hide the indicator.
@@ -240,12 +237,11 @@ export const AgentChatInput = memo<AgentChatInputProps>(
isInterruptPending = false,
hasQueuedMessages = false,
leftActions,
contextCompressionThreshold,
onContextCompressionThresholdChange,
contextUsage,
contextUsage,
sticky = false,
}) => {
const [input, setInput] = useState(initialValue);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleSubmit = useCallback(async () => {
const text = input.trim();
@@ -258,6 +254,9 @@ export const AgentChatInput = memo<AgentChatInputProps>(
onInputChange?.("");
} catch {
// Keep input on failure so the user can retry.
} finally {
// Re-focus the textarea so the user can keep typing.
textareaRef.current?.focus();
}
}, [input, isDisabled, hasModelOptions, onSend, onInputChange]);
@@ -275,6 +274,7 @@ export const AgentChatInput = memo<AgentChatInputProps>(
<div className="mx-auto w-full max-w-3xl pb-4">
<div className="rounded-2xl border border-border-default/80 bg-surface-secondary/45 p-1 shadow-sm focus-within:ring-2 focus-within:ring-content-link/40">
<TextareaAutosize
ref={textareaRef}
className="min-h-[120px] w-full resize-none border-none bg-transparent px-3 py-2 font-sans text-[15px] leading-6 text-content-primary outline-none placeholder:text-content-secondary disabled:cursor-not-allowed disabled:opacity-70"
placeholder={placeholder}
value={input}
@@ -300,26 +300,6 @@ export const AgentChatInput = memo<AgentChatInputProps>(
className="h-8 w-auto justify-start border-none bg-transparent px-1 text-xs shadow-none hover:bg-transparent [&>span]:!text-content-secondary"
/>
{leftActions}
{onContextCompressionThresholdChange &&
contextCompressionThreshold !== undefined && (
<div className="flex items-center gap-1">
<Input
type="number"
min={0}
max={100}
step={1}
value={contextCompressionThreshold}
onChange={(event) =>
onContextCompressionThresholdChange(
event.target.value,
)
}
className="h-7 w-16 border-border-default/70 bg-transparent px-2 text-xs"
disabled={isDisabled}
/>
<span className="text-xs text-content-secondary">%</span>
</div>
)}
{inputStatusText && (
<span className="hidden text-xs text-content-secondary sm:inline">
{inputStatusText}
+6 -84
View File
@@ -92,23 +92,6 @@ const asNonEmptyString = (value: unknown): string | undefined => {
return next.length > 0 ? next : undefined;
};
const defaultContextCompressionThreshold = "70";
const contextCompressionThresholdStorageKey = (modelID: string): string =>
`agents.context-compression-threshold.${modelID || "default"}`;
const parseContextCompressionThreshold = (
value: string,
): number | undefined => {
const parsedValue = Number.parseInt(value.trim(), 10);
if (!Number.isFinite(parsedValue)) {
return undefined;
}
if (parsedValue < 0 || parsedValue > 100) {
return undefined;
}
return parsedValue;
};
type ChatMessageWithUsage = TypesGen.ChatMessage & {
readonly input_tokens?: unknown;
@@ -890,23 +873,6 @@ const resolveModelFromChatConfig = (
return modelOptions[0]?.id ?? "";
};
const resolveContextCompressionThresholdFromChatConfig = (
modelConfig: TypesGen.Chat["model_config"],
): string | null => {
if (!modelConfig || typeof modelConfig !== "object") {
return null;
}
const typedModelConfig = modelConfig as Record<string, unknown>;
const threshold = typedModelConfig.context_compression_threshold;
if (typeof threshold !== "number" || !Number.isFinite(threshold)) {
return null;
}
if (threshold < 0 || threshold > 100) {
return null;
}
return String(Math.trunc(threshold));
};
type StreamToolCall = {
id: string;
@@ -1175,8 +1141,6 @@ export const AgentDetail: FC = () => {
Map<string, TypesGen.ChatStatus>
>(new Map());
const [selectedModel, setSelectedModel] = useState("");
const [contextCompressionThreshold, setContextCompressionThreshold] =
useState(defaultContextCompressionThreshold);
const chatErrorReasons = outletContext?.chatErrorReasons ?? {};
const setChatErrorReason =
outletContext?.setChatErrorReason ?? noopSetChatErrorReason;
@@ -1330,33 +1294,6 @@ export const AgentDetail: FC = () => {
});
}, [chatQuery.data, modelOptions]);
useEffect(() => {
if (!chatQuery.data) {
return;
}
const configuredModel = resolveModelFromChatConfig(
chatQuery.data.chat.model_config,
modelOptions,
);
const fromChatConfig = resolveContextCompressionThresholdFromChatConfig(
chatQuery.data.chat.model_config,
);
if (fromChatConfig !== null && selectedModel === configuredModel) {
setContextCompressionThreshold(fromChatConfig);
return;
}
if (typeof window === "undefined") {
return;
}
const storedThreshold = localStorage.getItem(
contextCompressionThresholdStorageKey(selectedModel),
);
setContextCompressionThreshold(
storedThreshold ?? defaultContextCompressionThreshold,
);
}, [chatQuery.data, modelOptions, selectedModel]);
useEffect(() => {
if (!agentId) {
@@ -1700,19 +1637,12 @@ export const AgentDetail: FC = () => {
: "No models configured. Ask an admin.";
const isSubmissionPending =
sendMutation.isPending || interruptMutation.isPending;
const isInputDisabled = isSubmissionPending || !hasModelOptions;
const handleContextCompressionThresholdChange = useCallback(
(value: string) => {
setContextCompressionThreshold(value);
if (typeof window !== "undefined") {
localStorage.setItem(
contextCompressionThresholdStorageKey(selectedModel),
value,
);
}
},
[selectedModel],
);
// Only disable the input when models are unavailable — NOT while a
// mutation is in flight. Disabling the textarea causes the browser
// to blur it, which means the user can't keep typing after sending
// a message (especially queued messages). The submit handler
// already guards against double-submission.
const isInputDisabled = !hasModelOptions;
// Stable callback refs — the actual implementation is updated on
// every render, but the reference passed to ChatInput never changes.
@@ -1730,14 +1660,10 @@ export const AgentDetail: FC = () => {
) {
return;
}
const parsedCompressionThreshold = parseContextCompressionThreshold(
contextCompressionThreshold,
);
const request: CreateChatMessagePayload = {
role: "user",
content: JSON.parse(JSON.stringify(message)),
model: selectedModel || undefined,
context_compression_threshold: parsedCompressionThreshold,
};
clearChatErrorReason(agentId);
setStreamError(null);
@@ -2217,10 +2143,6 @@ export const AgentDetail: FC = () => {
modelSelectorPlaceholder={modelSelectorPlaceholder}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
contextCompressionThreshold={contextCompressionThreshold}
onContextCompressionThresholdChange={
handleContextCompressionThresholdChange
}
sticky
/>
</div>
+84 -139
View File
@@ -1,6 +1,8 @@
import type { ChatModelsResponse } from "api/api";
import { watchChats } from "api/api";
import { getErrorMessage } from "api/errors";
import { chatModels, chats, createChat, deleteChat } from "api/queries/chats";
import { chatModels, chats, chatsKey, createChat, deleteChat } from "api/queries/chats";
import type * as TypesGen from "api/typesGenerated";
import { deploymentConfig } from "api/queries/deployment";
import { workspaces } from "api/queries/workspaces";
import { ErrorAlert } from "components/Alert/ErrorAlert";
@@ -11,7 +13,6 @@ import {
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "components/Dialog/Dialog";
@@ -26,7 +27,7 @@ import {
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import { useAuthenticated } from "hooks";
import type { LucideIcon } from "lucide-react";
import { BoxesIcon, KeyRoundIcon, Loader2Icon, MonitorIcon, UserIcon, XIcon } from "lucide-react";
import { BoxesIcon, KeyRoundIcon, MonitorIcon, UserIcon, XIcon } from "lucide-react";
import { UserDropdown } from "modules/dashboard/Navbar/UserDropdown/UserDropdown";
import { useDashboard } from "modules/dashboard/useDashboard";
import {
@@ -56,23 +57,6 @@ import {
const emptyInputStorageKey = "agents.empty-input";
const selectedWorkspaceIdStorageKey = "agents.selected-workspace-id";
const selectedWorkspaceModeStorageKey = "agents.selected-workspace-mode";
const defaultContextCompressionThreshold = "70";
const contextCompressionThresholdStorageKey = (modelID: string) =>
`agents.context-compression-threshold.${modelID || "default"}`;
const parseContextCompressionThreshold = (
value: string,
): number | undefined => {
const parsedValue = Number.parseInt(value.trim(), 10);
if (!Number.isFinite(parsedValue)) {
return undefined;
}
if (parsedValue < 0 || parsedValue > 100) {
return undefined;
}
return parsedValue;
};
type ChatModelOption = ModelSelectorOption;
@@ -82,7 +66,6 @@ type CreateChatOptions = {
workspaceMode?: "local";
model?: string;
systemPrompt?: string;
contextCompressionThreshold?: number;
};
type ConfigureAgentsSection = "providers" | "system-prompt" | "models";
@@ -123,7 +106,7 @@ export const AgentsPage: FC = () => {
});
const createMutation = useMutation(createChat(queryClient));
const archiveMutation = useMutation(deleteChat(queryClient));
const [archiveTargetChatId, setArchiveTargetChatId] = useState<string | null>(
const [archivingChatId, setArchivingChatId] = useState<string | null>(
null,
);
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
@@ -165,9 +148,36 @@ export const AgentsPage: FC = () => {
const topBarTitleRef = useRef<HTMLDivElement>(null);
const topBarActionsRef = useRef<HTMLDivElement>(null);
const rightPanelRef = useRef<HTMLDivElement>(null);
const requestArchiveAgent = useCallback((chatId: string) => {
setArchiveTargetChatId(chatId);
}, []);
const chatList = chatsQuery.data ?? [];
const requestArchiveAgent = useCallback(
async (chatId: string) => {
if (archiveMutation.isPending) {
return;
}
setArchivingChatId(chatId);
const nextChatId = (queryClient.getQueryData(chats().queryKey) as typeof chatList)?.find(
(chat) => chat.id !== chatId,
)?.id;
try {
await archiveMutation.mutateAsync(chatId);
clearChatErrorReason(chatId);
displaySuccess("Agent archived.");
if (chatId === agentId) {
navigate(nextChatId ? `/agents/${nextChatId}` : "/agents", {
replace: true,
});
}
} catch (error) {
displayError(getErrorMessage(error, "Failed to archive agent."));
} finally {
setArchivingChatId(null);
}
},
[archiveMutation, queryClient, chatList, agentId, navigate, clearChatErrorReason],
);
const outletContext: AgentsOutletContext = {
chatErrorReasons,
setChatErrorReason,
@@ -189,7 +199,6 @@ export const AgentsPage: FC = () => {
workspaceMode,
model,
systemPrompt,
contextCompressionThreshold,
} = options;
const createdChat = await createMutation.mutateAsync({
message,
@@ -200,7 +209,6 @@ export const AgentsPage: FC = () => {
workspace_mode: workspaceMode,
model,
system_prompt: systemPrompt,
context_compression_threshold: contextCompressionThreshold,
});
if (typeof window !== "undefined") {
@@ -217,6 +225,53 @@ export const AgentsPage: FC = () => {
navigate("/agents");
};
useEffect(() => {
const ws = watchChats();
ws.addEventListener("message", (event) => {
const sse = event.parsedMessage;
if (sse?.type !== "data" || !sse.data) {
return;
}
const chatEvent = sse.data as {
kind: string;
chat: TypesGen.Chat;
};
if (!chatEvent?.chat?.id) {
return;
}
const updatedChat = chatEvent.chat;
if (chatEvent.kind === "deleted") {
queryClient.setQueryData(
chatsKey,
(prev: TypesGen.Chat[] | undefined) =>
prev?.filter((c) => c.id !== updatedChat.id),
);
return;
}
queryClient.setQueryData(
chatsKey,
(prev: TypesGen.Chat[] | undefined) => {
if (!prev) return prev;
const exists = prev.some((c) => c.id === updatedChat.id);
if (exists) {
return prev.map((c) =>
c.id === updatedChat.id
? { ...c, status: updatedChat.status, title: updatedChat.title, updated_at: updatedChat.updated_at }
: c,
);
}
if (chatEvent.kind === "created") {
return [updatedChat, ...prev];
}
return prev;
},
);
});
return () => ws.close();
}, [queryClient]);
useEffect(() => {
document.title = pageTitle("Agents");
}, []);
@@ -227,42 +282,6 @@ export const AgentsPage: FC = () => {
}
}, [agentId]);
const chatList = chatsQuery.data ?? [];
const archiveTargetChat = archiveTargetChatId
? chatList.find((chat) => chat.id === archiveTargetChatId)
: undefined;
const handleCloseArchiveDialog = () => {
if (archiveMutation.isPending) {
return;
}
setArchiveTargetChatId(null);
};
const handleConfirmArchive = async () => {
if (!archiveTargetChatId || archiveMutation.isPending) {
return;
}
const archivedChatId = archiveTargetChatId;
const nextChatId = chatList.find((chat) => chat.id !== archivedChatId)?.id;
try {
await archiveMutation.mutateAsync(archivedChatId);
setArchiveTargetChatId(null);
clearChatErrorReason(archivedChatId);
displaySuccess("Agent archived.");
if (archivedChatId === agentId) {
navigate(nextChatId ? `/agents/${nextChatId}` : "/agents", {
replace: true,
});
}
} catch (error) {
displayError(getErrorMessage(error, "Failed to archive agent."));
}
};
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-surface-primary md:flex-row">
<div
@@ -280,7 +299,7 @@ export const AgentsPage: FC = () => {
onNewAgent={handleNewAgent}
isCreating={createMutation.isPending}
isArchiving={archiveMutation.isPending}
archivingChatId={archiveTargetChatId}
archivingChatId={archivingChatId}
isLoading={chatsQuery.isLoading}
loadError={chatsQuery.isError ? chatsQuery.error : undefined}
onRetryLoad={() => void chatsQuery.refetch()}
@@ -340,46 +359,7 @@ export const AgentsPage: FC = () => {
)}
</div>
<Dialog
open={archiveTargetChatId !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
handleCloseArchiveDialog();
}
}}
>
<DialogContent className="max-w-md p-6">
<DialogHeader className="space-y-2">
<DialogTitle>Archive agent</DialogTitle>
<DialogDescription>
{archiveTargetChat
? `Archive "${archiveTargetChat.title}"? This permanently deletes its chat history.`
: "Archive this agent? This permanently deletes its chat history."}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
size="sm"
variant="outline"
onClick={handleCloseArchiveDialog}
disabled={archiveMutation.isPending}
>
Cancel
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => void handleConfirmArchive()}
disabled={archiveMutation.isPending}
>
{archiveMutation.isPending && (
<Loader2Icon className="h-4 w-4 animate-spin" />
)}
Archive
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
@@ -418,8 +398,6 @@ const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
return localStorage.getItem(emptyInputStorageKey) ?? "";
}, []);
const [selectedModel, setSelectedModel] = useState(modelOptions[0]?.id ?? "");
const [contextCompressionThreshold, setContextCompressionThreshold] =
useState(defaultContextCompressionThreshold);
const [systemPrompt, setSystemPrompt] = useState("");
const [isConfigureAgentsDialogOpen, setConfigureAgentsDialogOpen] =
useState(false);
@@ -497,8 +475,6 @@ const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
selectedWorkspaceModeRef.current = selectedWorkspaceMode;
const selectedModelRef = useRef(selectedModel);
selectedModelRef.current = selectedModel;
const contextCompressionThresholdRef = useRef(contextCompressionThreshold);
contextCompressionThresholdRef.current = contextCompressionThreshold;
const systemPromptRef = useRef(systemPrompt);
systemPromptRef.current = systemPrompt;
@@ -511,17 +487,6 @@ const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
});
}, [modelOptions]);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const storedThreshold = localStorage.getItem(
contextCompressionThresholdStorageKey(selectedModel),
);
setContextCompressionThreshold(
storedThreshold ?? defaultContextCompressionThreshold,
);
}, [selectedModel]);
useEffect(() => {
if (
@@ -575,26 +540,11 @@ const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
}
}, []);
const handleContextCompressionThresholdChange = useCallback(
(value: string) => {
setContextCompressionThreshold(value);
if (typeof window !== "undefined") {
localStorage.setItem(
contextCompressionThresholdStorageKey(selectedModelRef.current),
value,
);
}
},
[],
);
const handleSend = useCallback(
async (message: string) => {
const trimmedSystemPrompt = systemPromptRef.current.trim();
const localWorkspaceMode = selectedWorkspaceModeRef.current === "local";
const parsedCompressionThreshold = parseContextCompressionThreshold(
contextCompressionThresholdRef.current,
);
await onCreateChat({
message,
workspaceId: localWorkspaceMode
@@ -606,7 +556,6 @@ const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
canSetSystemPrompt && trimmedSystemPrompt
? trimmedSystemPrompt
: undefined,
contextCompressionThreshold: parsedCompressionThreshold,
});
},
[onCreateChat, canSetSystemPrompt],
@@ -661,10 +610,6 @@ const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
hasModelOptions={hasModelOptions}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
contextCompressionThreshold={contextCompressionThreshold}
onContextCompressionThresholdChange={
handleContextCompressionThresholdChange
}
leftActions={
<Select
value={