WIP PR association

This commit is contained in:
Kyle Carberry
2026-02-24 11:23:18 -05:00
parent c415d8fedd
commit c49f79d24f
12 changed files with 812 additions and 266 deletions
+11 -3
View File
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"net/http"
"os"
"time"
"golang.org/x/xerrors"
@@ -38,8 +39,14 @@ func gitAskpass(agentAuth *AgentAuth) *serpent.Command {
return xerrors.Errorf("create agent client: %w", err)
}
workingDirectory, err := os.Getwd()
if err != nil {
workingDirectory = ""
}
token, err := client.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{
Match: host,
Match: host,
Workdir: workingDirectory,
})
if err != nil {
var apiError *codersdk.Error
@@ -66,8 +73,9 @@ func gitAskpass(agentAuth *AgentAuth) *serpent.Command {
for r := retry.New(250*time.Millisecond, 10*time.Second); r.Wait(ctx); {
token, err = client.ExternalAuth(ctx, agentsdk.ExternalAuthRequest{
Match: host,
Listen: true,
Match: host,
Listen: true,
Workdir: workingDirectory,
})
if err != nil {
continue
+54 -1
View File
@@ -1081,7 +1081,60 @@ func chatMessagesToPrompt(messages []database.ChatMessage) ([]api.Message, error
return nil, xerrors.Errorf("unsupported chat message role %q", message.Role)
}
}
return prompt, nil
return injectMissingToolResults(prompt), nil
}
// injectMissingToolResults scans the prompt for assistant messages
// that contain tool calls without corresponding tool result messages
// and injects synthetic "interrupted" tool results. This can happen
// when a chat is interrupted mid-tool-call: the assistant message
// with tool_use blocks is persisted but the tool results are not.
// The Anthropic API requires every tool_use to have a matching
// tool_result in the immediately following message.
func injectMissingToolResults(prompt []api.Message) []api.Message {
result := make([]api.Message, 0, len(prompt))
for i, msg := range prompt {
result = append(result, msg)
assistantMsg, ok := msg.(*api.AssistantMessage)
if !ok {
continue
}
toolCalls := extractToolCalls(assistantMsg.Content)
if len(toolCalls) == 0 {
continue
}
// Collect the tool call IDs that have results in the
// following tool message(s).
answered := make(map[string]struct{})
for j := i + 1; j < len(prompt); j++ {
toolMsg, ok := prompt[j].(*api.ToolMessage)
if !ok {
break
}
for _, tr := range toolMsg.Content {
answered[tr.ToolCallID] = struct{}{}
}
}
// Build synthetic results for any unanswered tool calls.
var missing []api.ToolResultBlock
for _, tc := range toolCalls {
if _, ok := answered[tc.ToolCallID]; !ok {
missing = append(missing, api.ToolResultBlock{
ToolCallID: tc.ToolCallID,
ToolName: tc.ToolName,
Result: map[string]any{"error": "tool call was interrupted and did not receive a result"},
IsError: true,
})
}
}
if len(missing) > 0 {
result = append(result, &api.ToolMessage{Content: missing})
}
}
return result
}
func prependSystemInstruction(prompt []api.Message, instruction string) []api.Message {
+183
View File
@@ -62,3 +62,186 @@ func TestChatMessagesToPrompt(t *testing.T) {
require.True(t, ok)
require.Len(t, extractToolCalls(assistantMsg.Content), 1)
}
func TestChatMessagesToPrompt_InjectsMissingToolResults(t *testing.T) {
t.Parallel()
t.Run("InterruptedAfterToolCall", func(t *testing.T) {
t.Parallel()
// Simulate an interrupted chat: assistant made tool calls
// but the processing was interrupted before tool results
// were saved.
userContent, err := json.Marshal(api.ContentFromText("hello"))
require.NoError(t, err)
assistantBlocks := append(api.ContentFromText("let me check"),
&api.ToolCallBlock{
ToolCallID: "call-1",
ToolName: toolReadFile,
Args: json.RawMessage(`{"path":"main.go"}`),
},
&api.ToolCallBlock{
ToolCallID: "call-2",
ToolName: toolExecute,
Args: json.RawMessage(`{"command":"ls"}`),
},
)
assistantContent, err := json.Marshal(assistantBlocks)
require.NoError(t, err)
messages := []database.ChatMessage{
{
Role: string(api.MessageRoleUser),
Content: pqtype.NullRawMessage{RawMessage: userContent, Valid: true},
},
{
Role: string(api.MessageRoleAssistant),
Content: pqtype.NullRawMessage{RawMessage: assistantContent, Valid: true},
},
}
prompt, err := chatMessagesToPrompt(messages)
require.NoError(t, err)
// Should have injected a tool message after the assistant.
require.Len(t, prompt, 3, "expected injected tool result message")
toolMsg, ok := prompt[2].(*api.ToolMessage)
require.True(t, ok, "third message should be a tool message")
require.Len(t, toolMsg.Content, 2, "should have results for both tool calls")
for _, result := range toolMsg.Content {
require.True(t, result.IsError, "injected result should be an error")
}
require.Equal(t, "call-1", toolMsg.Content[0].ToolCallID)
require.Equal(t, "call-2", toolMsg.Content[1].ToolCallID)
})
t.Run("PartialToolResults", func(t *testing.T) {
t.Parallel()
// Assistant made two tool calls but only one result was
// saved before interruption.
userContent, err := json.Marshal(api.ContentFromText("hello"))
require.NoError(t, err)
assistantBlocks := append(api.ContentFromText("working"),
&api.ToolCallBlock{
ToolCallID: "call-1",
ToolName: toolReadFile,
Args: json.RawMessage(`{"path":"a.go"}`),
},
&api.ToolCallBlock{
ToolCallID: "call-2",
ToolName: toolReadFile,
Args: json.RawMessage(`{"path":"b.go"}`),
},
)
assistantContent, err := json.Marshal(assistantBlocks)
require.NoError(t, err)
toolResults, err := json.Marshal([]api.ToolResultBlock{{
ToolCallID: "call-1",
ToolName: toolReadFile,
Result: map[string]any{"content": "file a"},
}})
require.NoError(t, err)
messages := []database.ChatMessage{
{
Role: string(api.MessageRoleUser),
Content: pqtype.NullRawMessage{RawMessage: userContent, Valid: true},
},
{
Role: string(api.MessageRoleAssistant),
Content: pqtype.NullRawMessage{RawMessage: assistantContent, Valid: true},
},
{
Role: string(api.MessageRoleTool),
Content: pqtype.NullRawMessage{RawMessage: toolResults, Valid: true},
},
}
prompt, err := chatMessagesToPrompt(messages)
require.NoError(t, err)
// Original 3 messages + 1 injected tool message for call-2.
require.Len(t, prompt, 4)
injectedMsg, ok := prompt[3].(*api.ToolMessage)
require.True(t, ok, "fourth message should be injected tool message")
require.Len(t, injectedMsg.Content, 1)
require.Equal(t, "call-2", injectedMsg.Content[0].ToolCallID)
require.True(t, injectedMsg.Content[0].IsError)
})
t.Run("NoToolCalls", func(t *testing.T) {
t.Parallel()
// Assistant message with no tool calls should not inject
// anything.
userContent, err := json.Marshal(api.ContentFromText("hi"))
require.NoError(t, err)
assistantContent, err := json.Marshal(api.ContentFromText("hello back"))
require.NoError(t, err)
messages := []database.ChatMessage{
{
Role: string(api.MessageRoleUser),
Content: pqtype.NullRawMessage{RawMessage: userContent, Valid: true},
},
{
Role: string(api.MessageRoleAssistant),
Content: pqtype.NullRawMessage{RawMessage: assistantContent, Valid: true},
},
}
prompt, err := chatMessagesToPrompt(messages)
require.NoError(t, err)
require.Len(t, prompt, 2, "no injection expected when no tool calls")
})
t.Run("AllToolResultsPresent", func(t *testing.T) {
t.Parallel()
// All tool calls already have results; nothing to inject.
userContent, err := json.Marshal(api.ContentFromText("hello"))
require.NoError(t, err)
assistantBlocks := append(api.ContentFromText("working"), &api.ToolCallBlock{
ToolCallID: "call-1",
ToolName: toolReadFile,
Args: json.RawMessage(`{"path":"x.go"}`),
})
assistantContent, err := json.Marshal(assistantBlocks)
require.NoError(t, err)
toolResults, err := json.Marshal([]api.ToolResultBlock{{
ToolCallID: "call-1",
ToolName: toolReadFile,
Result: map[string]any{"content": "data"},
}})
require.NoError(t, err)
messages := []database.ChatMessage{
{
Role: string(api.MessageRoleUser),
Content: pqtype.NullRawMessage{RawMessage: userContent, Valid: true},
},
{
Role: string(api.MessageRoleAssistant),
Content: pqtype.NullRawMessage{RawMessage: assistantContent, Valid: true},
},
{
Role: string(api.MessageRoleTool),
Content: pqtype.NullRawMessage{RawMessage: toolResults, Valid: true},
},
}
prompt, err := chatMessagesToPrompt(messages)
require.NoError(t, err)
require.Len(t, prompt, 3, "no injection when all results present")
})
}
+41 -5
View File
@@ -41,9 +41,39 @@ const (
chatDiffBackgroundRefreshTimeout = 20 * time.Second
chatRepositoryRefResolveTimeout = 8 * time.Second
githubAPIBaseURL = "https://api.github.com"
chatGitReferenceCommand = `sh -lc 'branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"; origin="$(git config --get remote.origin.url 2>/dev/null || true)"; if [ -n "$branch" ] && [ -n "$origin" ]; then printf "%s\n%s\n" "$branch" "$origin"; fi'`
chatGitReferenceCommandBody = `branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"; origin="$(git config --get remote.origin.url 2>/dev/null || true)"; if [ -n "$branch" ] && [ -n "$origin" ]; then printf "%s\n%s\n" "$branch" "$origin"; fi`
)
type chatWorkingDirectoryContextKey struct{}
func contextWithChatWorkingDirectory(ctx context.Context, workingDirectory string) context.Context {
workingDirectory = strings.TrimSpace(workingDirectory)
if workingDirectory == "" {
return ctx
}
return context.WithValue(ctx, chatWorkingDirectoryContextKey{}, workingDirectory)
}
func chatWorkingDirectoryFromContext(ctx context.Context) string {
workingDirectory, _ := ctx.Value(chatWorkingDirectoryContextKey{}).(string)
return strings.TrimSpace(workingDirectory)
}
func shellEscapeSingleQuoted(value string) string {
if value == "" {
return "''"
}
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
}
func buildChatGitReferenceCommand(workingDirectory string) string {
body := chatGitReferenceCommandBody
if strings.TrimSpace(workingDirectory) != "" {
return fmt.Sprintf("sh -lc 'cd \"$1\" && %s' -- %s", body, shellEscapeSingleQuoted(workingDirectory))
}
return fmt.Sprintf("sh -lc '%s'", body)
}
var (
githubPullRequestPathPattern = regexp.MustCompile(
`^https://github\.com/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)/pull/([0-9]+)(?:[/?#].*)?$`,
@@ -906,17 +936,22 @@ func shouldRefreshChatDiffStatus(status database.ChatDiffStatus, now time.Time,
return chatDiffStatusIsStale(status, now)
}
func (api *API) triggerWorkspaceChatDiffStatusRefresh(workspace database.Workspace) {
func (api *API) triggerWorkspaceChatDiffStatusRefresh(ctx context.Context, workspace database.Workspace) {
if workspace.ID == uuid.Nil || workspace.OwnerID == uuid.Nil {
return
}
go func(workspaceID, workspaceOwnerID uuid.UUID) {
workingDirectory := chatWorkingDirectoryFromContext(ctx)
go func(workspaceID, workspaceOwnerID uuid.UUID, workingDirectory string) {
ctx := api.ctx
if ctx == nil {
ctx = context.Background()
}
ctx = dbauthz.AsSystemRestricted(ctx)
if workingDirectory != "" {
ctx = contextWithChatWorkingDirectory(ctx, workingDirectory)
}
chats, err := api.Database.GetChatsByOwnerID(ctx, workspaceOwnerID)
if err != nil {
@@ -942,7 +977,7 @@ func (api *API) triggerWorkspaceChatDiffStatusRefresh(workspace database.Workspa
api.publishChatStatusEvent(chat)
}
}(workspace.ID, workspace.OwnerID)
}(workspace.ID, workspace.OwnerID, workingDirectory)
}
func filterChatsByWorkspaceID(chats []database.Chat, workspaceID uuid.UUID) []database.Chat {
@@ -1193,7 +1228,8 @@ func (api *API) detectChatGitBranchAndOrigin(
execCtx, cancel := context.WithTimeout(ctx, chatRepositoryRefResolveTimeout)
defer cancel()
output, _, err := executeChatWorkspaceCommand(execCtx, conn, chatGitReferenceCommand)
command := buildChatGitReferenceCommand(chatWorkingDirectoryFromContext(ctx))
output, _, err := executeChatWorkspaceCommand(execCtx, conn, command)
if err != nil {
return "", "", xerrors.Errorf("resolve git repository reference: %w", err)
}
+28
View File
@@ -1,6 +1,7 @@
package coderd
import (
"context"
"database/sql"
"net/http"
"regexp"
@@ -28,6 +29,33 @@ func TestParseChatGitReferenceOutput(t *testing.T) {
require.Empty(t, origin)
}
func TestBuildChatGitReferenceCommandWorkdir(t *testing.T) {
t.Parallel()
noWorkdirCommand := buildChatGitReferenceCommand("")
require.Equal(t, "sh -lc '"+chatGitReferenceCommandBody+"'", noWorkdirCommand)
workdirCommand := buildChatGitReferenceCommand("/tmp/repo with 'quote'")
require.Equal(
t,
"sh -lc 'cd \"$1\" && "+chatGitReferenceCommandBody+"' -- '/tmp/repo with '\"'\"'quote'\"'\"''",
workdirCommand,
)
}
func TestChatWorkingDirectoryContextWorkingDir(t *testing.T) {
t.Parallel()
ctx := context.Background()
require.Empty(t, chatWorkingDirectoryFromContext(ctx))
ctx = contextWithChatWorkingDirectory(ctx, " /tmp/repo ")
require.Equal(t, "/tmp/repo", chatWorkingDirectoryFromContext(ctx))
ctx = contextWithChatWorkingDirectory(ctx, " ")
require.Equal(t, "/tmp/repo", chatWorkingDirectoryFromContext(ctx))
}
func TestParseGitHubRepositoryOrigin(t *testing.T) {
t.Parallel()
+9 -3
View File
@@ -139,6 +139,7 @@ const AgentAPIVersionREST = "1.0"
func (api *API) patchWorkspaceAgentLogs(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workspaceAgent := httpmw.WorkspaceAgent(r)
ctx = contextWithChatWorkingDirectory(ctx, workspaceAgent.ExpandedDirectory)
var req agentsdk.PatchLogs
if !httpapi.Read(ctx, rw, r, &req) {
@@ -1827,11 +1828,16 @@ func convertWorkspaceAgentMetadata(db []database.WorkspaceAgentMetadatum) []code
// @Param match query string true "Match"
// @Param id query string true "Provider ID"
// @Param listen query bool false "Wait for a new token to be issued"
// @Param workdir query string false "Working directory used for git context refresh"
// @Success 200 {object} agentsdk.ExternalAuthResponse
// @Router /workspaceagents/me/external-auth [get]
func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
query := r.URL.Query()
workdir := strings.TrimSpace(query.Get("workdir"))
if workdir != "" {
ctx = contextWithChatWorkingDirectory(ctx, workdir)
}
// Either match or configID must be provided!
match := query.Get("match")
if match == "" {
@@ -1854,7 +1860,7 @@ func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Requ
// listen determines if the request will wait for a
// new token to be issued!
listen := r.URL.Query().Has("listen")
listen := query.Has("listen")
var externalAuthConfig *externalauth.Config
for _, extAuth := range api.ExternalAuthConfigs {
@@ -1996,7 +2002,7 @@ func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Requ
})
return
}
api.triggerWorkspaceChatDiffStatusRefresh(workspace)
api.triggerWorkspaceChatDiffStatusRefresh(ctx, workspace)
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
@@ -2070,7 +2076,7 @@ func (api *API) workspaceAgentsExternalAuthListen(ctx context.Context, rw http.R
})
return
}
api.triggerWorkspaceChatDiffStatusRefresh(workspace)
api.triggerWorkspaceChatDiffStatusRefresh(ctx, workspace)
httpapi.Write(ctx, rw, http.StatusOK, resp)
return
}
+6
View File
@@ -645,6 +645,9 @@ type ExternalAuthRequest struct {
ID string
// Match is an arbitrary string matched against the regex of the provider.
Match string
// Workdir is an optional working directory used for follow-up workspace
// context refreshes.
Workdir string
// Listen indicates that the request should be long-lived and listen for
// a new token to be requested.
Listen bool
@@ -660,6 +663,9 @@ func (c *Client) ExternalAuth(ctx context.Context, req ExternalAuthRequest) (Ext
if req.Listen {
q.Set("listen", "true")
}
if req.Workdir != "" {
q.Set("workdir", req.Workdir)
}
reqURL := "/api/v2/workspaceagents/me/external-auth?" + q.Encode()
res, err := c.SDK.Request(ctx, http.MethodGet, reqURL, nil)
if err != nil {
+49
View File
@@ -153,3 +153,52 @@ func TestRewriteDERPMap(t *testing.T) {
require.Equal(t, "coconuts.org", node.HostName)
require.Equal(t, 44558, node.DERPPort)
}
func TestExternalAuthRequestWorkdirQuery(t *testing.T) {
t.Parallel()
t.Run("IncludesWorkdirWhenSet", func(t *testing.T) {
t.Parallel()
const expectedWorkdir = "/tmp/repo with spaces"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v2/workspaceagents/me/external-auth", r.URL.Path)
require.Equal(t, "true", r.URL.Query().Get("listen"))
require.Equal(t, expectedWorkdir, r.URL.Query().Get("workdir"))
_, _ = w.Write([]byte(`{"type":"github","access_token":"token"}`))
}))
defer srv.Close()
parsedURL, err := url.Parse(srv.URL)
require.NoError(t, err)
client := agentsdk.New(parsedURL, agentsdk.WithFixedToken("token"))
_, err = client.ExternalAuth(testutil.Context(t, testutil.WaitShort), agentsdk.ExternalAuthRequest{
Match: "github.com",
Listen: true,
Workdir: expectedWorkdir,
})
require.NoError(t, err)
})
t.Run("OmitsWorkdirWhenNotSet", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v2/workspaceagents/me/external-auth", r.URL.Path)
require.Equal(t, "", r.URL.Query().Get("workdir"))
require.False(t, r.URL.Query().Has("workdir"))
_, _ = w.Write([]byte(`{"type":"github","access_token":"token"}`))
}))
defer srv.Close()
parsedURL, err := url.Parse(srv.URL)
require.NoError(t, err)
client := agentsdk.New(parsedURL, agentsdk.WithFixedToken("token"))
_, err = client.ExternalAuth(testutil.Context(t, testutil.WaitShort), agentsdk.ExternalAuthRequest{
Match: "github.com",
})
require.NoError(t, err)
})
}
+1 -1
View File
@@ -79,7 +79,7 @@ export const Response = forwardRef<HTMLDivElement, ResponseProps>(
return (
<div
ref={ref}
className={cn("text-sm leading-relaxed text-content-primary", className)}
className={cn("text-[13px] leading-relaxed text-content-primary", className)}
{...props}
>
<Streamdown controls={false} components={components}>
-5
View File
@@ -136,11 +136,6 @@
margin-right: 0;
}
/* Prevent layout shift when modals open by maintaining scrollbar width */
html {
scrollbar-gutter: stable;
}
/*
This is a temporary fix for MUI Modals/Popovers until they are removed.
When html has scrollbar-gutter: stable, the browser reserves space for the scrollbar.
+388 -247
View File
@@ -1,4 +1,4 @@
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type FC, memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useOutletContext, useParams } from "react-router";
import TextareaAutosize from "react-textarea-autosize";
import { useMutation, useQuery, useQueryClient } from "react-query";
@@ -452,11 +452,260 @@ const DiffStatsBadge: FC<DiffStatsBadgeProps> = ({
);
};
// ---------------------------------------------------------------------------
// Memoized sub-components
// ---------------------------------------------------------------------------
/**
* Renders a single historic chat message. Wrapped in React.memo so
* it only re-renders when its own parsed content changes — not on
* every stream chunk or input keystroke.
*/
const ChatMessageItem = memo<{
message: TypesGen.ChatMessage;
parsed: ParsedMessageContent;
}>(({ message, parsed }) => {
const isUser = message.role === "user";
// Skip messages that only carry tool results. Those results
// are shown inline with the tool-call message they belong to.
if (
parsed.toolResults.length > 0 &&
parsed.toolCalls.length === 0 &&
parsed.markdown === "" &&
parsed.reasoning === ""
) {
return null;
}
const hasRenderableContent =
parsed.markdown !== "" ||
parsed.reasoning !== "" ||
parsed.tools.length > 0;
return (
<ConversationItem role={isUser ? "user" : "assistant"}>
{isUser ? (
<Message className="max-w-[min(44rem,78%)]">
<MessageContent className="rounded-2xl border border-border-default/60 bg-surface-tertiary/75 px-4 py-2.5 font-sans shadow-sm">
{parsed.markdown || ""}
</MessageContent>
</Message>
) : (
<Message>
<MessageContent className="whitespace-normal">
<div className="space-y-3">
{parsed.markdown && (
<Response>{parsed.markdown}</Response>
)}
{parsed.reasoning && (
<Thinking>{parsed.reasoning}</Thinking>
)}
{parsed.tools.map((tool) => (
<Tool
key={tool.id}
name={tool.name}
args={tool.args}
result={tool.result}
status={tool.status}
isError={tool.isError}
/>
))}
{!hasRenderableContent && (
<div className="text-xs text-content-secondary">
Message has no renderable content.
</div>
)}
</div>
</MessageContent>
</Message>
)}
</ConversationItem>
);
});
ChatMessageItem.displayName = "ChatMessageItem";
/**
* Renders the live streaming assistant output. Isolated via memo so
* historic messages and the input area are not re-rendered on each
* chunk.
*/
const StreamingOutput = memo<{
streamState: StreamState | null;
streamTools: MergedTool[];
}>(({ streamState, streamTools }) => (
<ConversationItem role="assistant">
<Message>
<MessageContent className="whitespace-normal">
<div className="space-y-3">
{streamState?.content ? (
<Response>{streamState.content}</Response>
) : (
<div className="text-sm text-content-secondary">
Agent is thinking...
</div>
)}
{streamState?.reasoning && (
<Thinking>{streamState.reasoning}</Thinking>
)}
{streamTools.map((tool) => (
<Tool
key={tool.id}
name={tool.name}
args={tool.args}
result={tool.result}
status={tool.status}
isError={tool.isError}
/>
))}
</div>
</MessageContent>
</Message>
</ConversationItem>
));
StreamingOutput.displayName = "StreamingOutput";
/**
* The chat input area with textarea, model selector, and action
* buttons. Manages its own input state so that keystrokes never
* trigger re-renders of the message list or streaming output.
*/
interface ChatInputProps {
onSend: (message: string) => Promise<void>;
onInterrupt: () => void;
isStreaming: boolean;
isDisabled: boolean;
isInterruptPending: boolean;
hasModelOptions: boolean;
selectedModel: string;
onModelChange: (value: string) => void;
modelOptions: readonly ChatModelOption[];
modelSelectorPlaceholder: string;
inputStatusText: string | null;
modelCatalogStatusMessage: string | null;
}
const ChatInput = memo<ChatInputProps>(({
onSend,
onInterrupt,
isStreaming,
isDisabled,
isInterruptPending,
hasModelOptions,
selectedModel,
onModelChange,
modelOptions,
modelSelectorPlaceholder,
inputStatusText,
modelCatalogStatusMessage,
}) => {
const [input, setInput] = useState("");
const handleSubmit = useCallback(async () => {
const text = input.trim();
if (!text || isDisabled || !hasModelOptions) {
return;
}
try {
await onSend(input);
setInput("");
} catch {
// Keep input on failure so the user can retry.
}
}, [input, isDisabled, hasModelOptions, onSend]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSubmit();
}
},
[handleSubmit],
);
return (
<div className="sticky bottom-0 z-50 bg-surface-primary">
<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
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="Type a message..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isDisabled}
minRows={4}
/>
<div className="flex items-center justify-between gap-2 px-2.5 pb-1.5">
<div className="flex min-w-0 items-center gap-2">
<ModelSelector
value={selectedModel}
onValueChange={onModelChange}
options={modelOptions}
disabled={isDisabled}
placeholder={modelSelectorPlaceholder}
formatProviderLabel={formatProviderLabel}
dropdownSide="top"
dropdownAlign="start"
className="h-8 justify-start border-none bg-transparent px-1 text-xs shadow-none hover:bg-transparent"
/>
{inputStatusText && (
<span className="hidden text-xs text-content-secondary sm:inline">
{inputStatusText}
</span>
)}
</div>
<div className="flex items-center gap-2">
{isStreaming && (
<Button
size="icon"
variant="outline"
className="rounded-full"
onClick={onInterrupt}
disabled={isInterruptPending}
>
<Square className="h-4 w-4" />
<span className="sr-only">Interrupt</span>
</Button>
)}
<Button
size="icon"
variant="default"
className="rounded-full"
onClick={() => void handleSubmit()}
disabled={isDisabled || !hasModelOptions || !input.trim()}
>
<SendIcon />
<span className="sr-only">Send</span>
</Button>
</div>
</div>
{inputStatusText && (
<div className="px-2.5 pb-1 text-xs text-content-secondary sm:hidden">
{inputStatusText}
</div>
)}
{modelCatalogStatusMessage && (
<div className="px-2.5 pb-1 text-2xs text-content-secondary">
{modelCatalogStatusMessage}
</div>
)}
</div>
</div>
</div>
);
});
ChatInput.displayName = "ChatInput";
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export const AgentDetail: FC = () => {
const { agentId } = useParams<{ agentId: string }>();
const outletContext = useOutletContext<AgentsOutletContext | undefined>();
const queryClient = useQueryClient();
const [input, setInput] = useState("");
const [messagesById, setMessagesById] = useState<
Map<number, TypesGen.ChatMessage>
>(new Map());
@@ -617,57 +866,66 @@ export const AgentDetail: FC = () => {
}
cancelScheduledStreamReset();
// Wrap stream state updates in startTransition so React
// treats them as low-priority — keeping user interactions
// (typing, clicking) responsive during rapid streaming.
switch (normalizeBlockType(part.type)) {
case "text":
setStreamState((prev) => {
const nextState: StreamState = prev ?? createEmptyStreamState();
return {
...nextState,
content: `${nextState.content}${asString(part.text)}`,
};
startTransition(() => {
setStreamState((prev) => {
const nextState: StreamState = prev ?? createEmptyStreamState();
return {
...nextState,
content: `${nextState.content}${asString(part.text)}`,
};
});
});
return;
case "reasoning":
case "thinking":
setStreamState((prev) => {
const nextState: StreamState = prev ?? createEmptyStreamState();
return {
...nextState,
reasoning: `${nextState.reasoning}${asString(part.text)}`,
};
startTransition(() => {
setStreamState((prev) => {
const nextState: StreamState = prev ?? createEmptyStreamState();
return {
...nextState,
reasoning: `${nextState.reasoning}${asString(part.text)}`,
};
});
});
return;
case "tool-call":
case "toolcall": {
const toolName = asString(part.tool_name);
setStreamState((prev) => {
const nextState: StreamState = prev ?? createEmptyStreamState();
const existingByName = Object.values(nextState.toolCalls).find(
(call) => call.name === toolName,
);
const toolCallID =
asString(part.tool_call_id) ||
existingByName?.id ||
`tool-call-${Object.keys(nextState.toolCalls).length + 1}`;
const existing = nextState.toolCalls[toolCallID];
const nextArgs = mergeStreamPayload(
existing?.args,
part.args,
part.args_delta,
);
startTransition(() => {
setStreamState((prev) => {
const nextState: StreamState = prev ?? createEmptyStreamState();
const existingByName = Object.values(nextState.toolCalls).find(
(call) => call.name === toolName,
);
const toolCallID =
asString(part.tool_call_id) ||
existingByName?.id ||
`tool-call-${Object.keys(nextState.toolCalls).length + 1}`;
const existing = nextState.toolCalls[toolCallID];
const nextArgs = mergeStreamPayload(
existing?.args,
part.args,
part.args_delta,
);
return {
...nextState,
toolCalls: {
...nextState.toolCalls,
[toolCallID]: {
id: toolCallID,
name: toolName || existing?.name || "Tool",
args: nextArgs,
return {
...nextState,
toolCalls: {
...nextState.toolCalls,
[toolCallID]: {
id: toolCallID,
name: toolName || existing?.name || "Tool",
args: nextArgs,
},
},
},
};
};
});
});
return;
}
@@ -675,38 +933,40 @@ export const AgentDetail: FC = () => {
case "toolresult": {
const toolName = asString(part.tool_name);
setStreamState((prev) => {
const nextState: StreamState = prev ?? createEmptyStreamState();
const existingByName = Object.values(nextState.toolResults).find(
(result) => result.name === toolName,
);
const existingCallByName = Object.values(nextState.toolCalls).find(
(call) => call.name === toolName,
);
const toolCallID =
asString(part.tool_call_id) ||
existingByName?.id ||
existingCallByName?.id ||
`tool-result-${Object.keys(nextState.toolResults).length + 1}`;
const existing = nextState.toolResults[toolCallID];
const nextResult = mergeStreamPayload(
existing?.result,
part.result,
part.result_delta,
);
startTransition(() => {
setStreamState((prev) => {
const nextState: StreamState = prev ?? createEmptyStreamState();
const existingByName = Object.values(nextState.toolResults).find(
(result) => result.name === toolName,
);
const existingCallByName = Object.values(nextState.toolCalls).find(
(call) => call.name === toolName,
);
const toolCallID =
asString(part.tool_call_id) ||
existingByName?.id ||
existingCallByName?.id ||
`tool-result-${Object.keys(nextState.toolResults).length + 1}`;
const existing = nextState.toolResults[toolCallID];
const nextResult = mergeStreamPayload(
existing?.result,
part.result,
part.result_delta,
);
return {
...nextState,
toolResults: {
...nextState.toolResults,
[toolCallID]: {
id: toolCallID,
name: toolName || existing?.name || "Tool",
result: nextResult,
isError: existing?.isError || Boolean(part.is_error),
return {
...nextState,
toolResults: {
...nextState.toolResults,
[toolCallID]: {
id: toolCallID,
name: toolName || existing?.name || "Tool",
result: nextResult,
isError: existing?.isError || Boolean(part.is_error),
},
},
},
};
};
});
});
return;
}
@@ -825,41 +1085,45 @@ export const AgentDetail: FC = () => {
const isSubmissionPending = sendMutation.isPending || interruptMutation.isPending;
const isInputDisabled = isSubmissionPending || !hasModelOptions;
const handleSend = async () => {
const prompt = input;
if (!prompt.trim() || isSubmissionPending || !agentId || !hasModelOptions) {
// Stable callback refs — the actual implementation is updated on
// every render, but the reference passed to ChatInput never changes.
// This prevents ChatInput from re-rendering when unrelated parent
// state (streamState, messagesById) changes.
const handleSendRef = useRef<(message: string) => Promise<void>>(async () => {});
handleSendRef.current = async (message: string) => {
if (!message.trim() || isSubmissionPending || !agentId || !hasModelOptions) {
return;
}
if (isStreaming) {
await interruptMutation.mutateAsync();
}
// Content is a JSON raw message; preserve plain text message shape.
const request: CreateChatMessagePayload = {
role: "user",
content: JSON.parse(JSON.stringify(prompt)),
content: JSON.parse(JSON.stringify(message)),
model: selectedModel || undefined,
};
clearChatErrorReason(agentId);
setStreamError(null);
await sendMutation.mutateAsync(request);
setInput("");
};
const handleInterrupt = async () => {
const handleInterruptRef = useRef<() => void>(() => {});
handleInterruptRef.current = () => {
if (!agentId || interruptMutation.isPending) {
return;
}
await interruptMutation.mutateAsync();
void interruptMutation.mutateAsync();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
};
// Stable wrappers that never change identity.
const stableOnSend = useCallback(
(message: string) => handleSendRef.current(message),
[],
);
const stableOnInterrupt = useCallback(
() => handleInterruptRef.current(),
[],
);
const streamTools = useMemo((): MergedTool[] => {
if (!streamState) {
@@ -910,6 +1174,19 @@ export const AgentDetail: FC = () => {
[visibleMessages],
);
// Pre-compute parsed content for all visible messages. This runs
// only when visibleMessages changes (at message boundaries), not
// on every stream chunk. Each entry keeps a stable reference so
// ChatMessageItem can bail out via React.memo.
const parsedMessages = useMemo(
() =>
visibleMessages.map((message) => ({
message,
parsed: parseChatMessageContent(message, globalToolResults),
})),
[visibleMessages, globalToolResults],
);
if (chatQuery.isLoading) {
return (
<div className="flex flex-1 items-center justify-center">
@@ -948,106 +1225,25 @@ export const AgentDetail: FC = () => {
<div className="flex h-full flex-col-reverse overflow-y-auto [scrollbar-width:thin]">
<div>
<div className="mx-auto w-full max-w-3xl py-6">
{visibleMessages.length === 0 && !hasStreamOutput ? (
{parsedMessages.length === 0 && !hasStreamOutput ? (
<div className="py-12 text-center text-content-secondary">
<p className="text-sm">Start a conversation with your agent.</p>
</div>
) : (
<Conversation>
{visibleMessages.map((message) => {
const parsed = parseChatMessageContent(message, globalToolResults);
const isUser = message.role === "user";
// Skip messages that only carry tool results.
// Those results are now shown inline with the
// tool-call message they belong to.
if (
parsed.toolResults.length > 0 &&
parsed.toolCalls.length === 0 &&
parsed.markdown === "" &&
parsed.reasoning === ""
) {
return null;
}
const hasRenderableContent =
parsed.markdown !== "" ||
parsed.reasoning !== "" ||
parsed.tools.length > 0;
return (
<ConversationItem
key={message.id}
role={isUser ? "user" : "assistant"}
>
{isUser ? (
<Message className="max-w-[min(44rem,78%)]">
<MessageContent className="rounded-2xl border border-border-default/60 bg-surface-tertiary/75 px-4 py-2.5 font-sans shadow-sm">
{parsed.markdown || ""}
</MessageContent>
</Message>
) : (
<Message>
<MessageContent className="whitespace-normal">
<div className="space-y-3">
{parsed.markdown && (
<Response>{parsed.markdown}</Response>
)}
{parsed.reasoning && (
<Thinking>{parsed.reasoning}</Thinking>
)}
{parsed.tools.map((tool) => (
<Tool
key={tool.id}
name={tool.name}
args={tool.args}
result={tool.result}
status={tool.status}
isError={tool.isError}
/>
))}
{!hasRenderableContent && (
<div className="text-xs text-content-secondary">
Message has no renderable content.
</div>
)}
</div>
</MessageContent>
</Message>
)}
</ConversationItem>
);
})}
{parsedMessages.map(({ message, parsed }) => (
<ChatMessageItem
key={message.id}
message={message}
parsed={parsed}
/>
))}
{hasStreamOutput && (
<ConversationItem role="assistant">
<Message>
<MessageContent className="whitespace-normal">
<div className="space-y-3">
{streamState?.content ? (
<Response>{streamState.content}</Response>
) : (
<div className="text-sm text-content-secondary">
Agent is thinking...
</div>
)}
{streamState?.reasoning && (
<Thinking>{streamState.reasoning}</Thinking>
)}
{streamTools.map((tool) => (
<Tool
key={tool.id}
name={tool.name}
args={tool.args}
result={tool.result}
status={tool.status}
isError={tool.isError}
/>
))}
</div>
</MessageContent>
</Message>
</ConversationItem>
<StreamingOutput
streamState={streamState}
streamTools={streamTools}
/>
)}
</Conversation>
)}
@@ -1059,75 +1255,20 @@ export const AgentDetail: FC = () => {
)}
</div>
<div className="sticky bottom-0 z-50 bg-surface-primary">
<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
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="Type a message..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isInputDisabled}
minRows={4}
/>
<div className="flex items-center justify-between gap-2 px-2.5 pb-1.5">
<div className="flex min-w-0 items-center gap-2">
<ModelSelector
value={selectedModel}
onValueChange={setSelectedModel}
options={modelOptions}
disabled={isInputDisabled}
placeholder={modelSelectorPlaceholder}
formatProviderLabel={formatProviderLabel}
dropdownSide="top"
dropdownAlign="start"
className="h-8 justify-start border-none bg-transparent px-1 text-xs shadow-none hover:bg-transparent"
/>
{inputStatusText && (
<span className="hidden text-xs text-content-secondary sm:inline">
{inputStatusText}
</span>
)}
</div>
<div className="flex items-center gap-2">
{isStreaming && (
<Button
size="icon"
variant="outline"
className="rounded-full"
onClick={() => void handleInterrupt()}
disabled={interruptMutation.isPending}
>
<Square className="h-4 w-4" />
<span className="sr-only">Interrupt</span>
</Button>
)}
<Button
size="icon"
variant="default"
className="rounded-full"
onClick={() => void handleSend()}
disabled={isInputDisabled || !hasModelOptions || !input.trim()}
>
<SendIcon />
<span className="sr-only">Send</span>
</Button>
</div>
</div>
{inputStatusText && (
<div className="px-2.5 pb-1 text-xs text-content-secondary sm:hidden">
{inputStatusText}
</div>
)}
{modelCatalogStatusMessage && (
<div className="px-2.5 pb-1 text-2xs text-content-secondary">
{modelCatalogStatusMessage}
</div>
)}
</div>
</div>
</div>
<ChatInput
onSend={stableOnSend}
onInterrupt={stableOnInterrupt}
isStreaming={isStreaming}
isDisabled={isInputDisabled}
isInterruptPending={interruptMutation.isPending}
hasModelOptions={hasModelOptions}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
/>
</div>
</div>
</div>
+42 -1
View File
@@ -1,5 +1,5 @@
import { type FC, useMemo, useState } from "react";
import type { Chat, ChatStatus } from "api/typesGenerated";
import type { Chat, ChatDiffStatus, ChatStatus } from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { shortRelativeTime } from "utils/time";
import { cn } from "utils/cn";
@@ -81,6 +81,14 @@ const getModelDisplayName = (
return model;
};
type ChatWithDiffStatus = Chat & {
readonly diff_status?: ChatDiffStatus;
};
const getChatDiffStatus = (chat: Chat): ChatDiffStatus | undefined => {
return (chat as ChatWithDiffStatus).diff_status;
};
export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
const {
chats,
@@ -146,6 +154,15 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
const errorReason =
chat.status === "error" ? chatErrorReasons[chat.id] : undefined;
const subtitle = errorReason || modelName;
const diffStatus = getChatDiffStatus(chat);
const hasLinkedDiffStatus = Boolean(diffStatus?.pull_request_url);
const changedFiles = diffStatus?.changed_files ?? 0;
const additions = diffStatus?.additions ?? 0;
const deletions = diffStatus?.deletions ?? 0;
const hasLineStats = additions > 0 || deletions > 0;
const filesChangedLabel = `${changedFiles} ${
changedFiles === 1 ? "file" : "files"
}`;
const isArchivingThisChat =
isArchiving && archivingChatId === chat.id;
@@ -194,6 +211,30 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
>
{subtitle}
</div>
{hasLinkedDiffStatus && (
<div className="flex min-w-0 justify-end">
<div
className="inline-flex max-w-full items-center gap-1 rounded border border-border-default/80 bg-surface-tertiary/30 px-1.5 py-0.5 text-[10px] leading-none text-content-secondary tabular-nums"
title={hasLineStats
? `${filesChangedLabel}, +${additions} -${deletions}`
: filesChangedLabel}
>
<span className="truncate">
{filesChangedLabel}
</span>
{hasLineStats && (
<>
<span className="text-content-success">
+{additions}
</span>
<span className="text-content-destructive">
-{deletions}
</span>
</>
)}
</div>
</div>
)}
</div>
</>
)}