mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
WIP PR association
This commit is contained in:
+11
-3
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user