Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion chat/src/components/chat-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,18 @@ export function ChatProvider({ children }: PropsWithChildren) {
const [serverStatus, setServerStatus] = useState<ServerStatus>("unknown");
const eventSourceRef = useRef<EventSource | null>(null);
const searchParams = useSearchParams();
const agentAPIUrl = searchParams.get("url") || window.location.origin;
// NOTE(cian): We use '../../' here to construct the agent API URL relative
// to the current window location. Let's say the app is hosted on a subpath
// `/@admin/workspace.agent/apps/ccw/`. When you visit this URL you get
// redirected to `/@admin/workspace.agent/apps/ccw/chat/embed`. This serves
// this React application, but it needs to know where the agent API is hosted.
// This will be at the root of where the application is mounted e.g.
// `/@admin/workspace.agent/apps/ccw/`. Previously we used
// `window.location.origin` but this assumes that the application owns the
// entire origin.
// See: https://github.com/coder/coder/issues/18779#issuecomment-3133290494 for more context.
const defaultAgentAPIURL = new URL("../../", window.location.href).toString();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm afraid this won't always work. Chat is hosted both at /chat and /chat/embed - the /chat/embed route has a simplified UI.

I created #42 to suggest a workaround.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oof, TIL

const agentAPIUrl = searchParams.get("url") || defaultAgentAPIURL;

// Set up SSE connection to the events endpoint
useEffect(() => {
Expand Down
26 changes: 20 additions & 6 deletions lib/httpapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"log/slog"
"net/http"
"net/url"
"sync"
"time"

Expand Down Expand Up @@ -33,6 +34,7 @@ type Server struct {
agentio *termexec.Process
agentType mf.AgentType
emitter *EventEmitter
chatBasePath string
}

func (s *Server) GetOpenAPI() string {
Expand Down Expand Up @@ -95,14 +97,20 @@ func NewServer(ctx context.Context, agentType mf.AgentType, process *termexec.Pr
agentio: process,
agentType: agentType,
emitter: emitter,
chatBasePath: chatBasePath,
}

// Register API routes
s.registerRoutes(chatBasePath)
s.registerRoutes()

return s
}

// Handler returns the underlying chi.Router for testing purposes.
func (s *Server) Handler() http.Handler {
return s.router
}

func (s *Server) StartSnapshotLoop(ctx context.Context) {
s.conversation.StartSnapshotLoop(ctx)
go func() {
Expand All @@ -116,7 +124,7 @@ func (s *Server) StartSnapshotLoop(ctx context.Context) {
}

// registerRoutes sets up all API endpoints
func (s *Server) registerRoutes(chatBasePath string) {
func (s *Server) registerRoutes() {
// GET /status endpoint
huma.Get(s.api, "/status", s.getStatus, func(o *huma.Operation) {
o.Description = "Returns the current status of the agent."
Expand Down Expand Up @@ -158,7 +166,7 @@ func (s *Server) registerRoutes(chatBasePath string) {
s.router.Handle("/", http.HandlerFunc(s.redirectToChat))

// Serve static files for the chat interface under /chat
s.registerStaticFileRoutes(chatBasePath)
s.registerStaticFileRoutes()
}

// getStatus handles GET /status
Expand Down Expand Up @@ -305,14 +313,20 @@ func (s *Server) Stop(ctx context.Context) error {
}

// registerStaticFileRoutes sets up routes for serving static files
func (s *Server) registerStaticFileRoutes(chatBasePath string) {
chatHandler := FileServerWithIndexFallback(chatBasePath)
func (s *Server) registerStaticFileRoutes() {
chatHandler := FileServerWithIndexFallback(s.chatBasePath)

// Mount the file server at /chat
s.router.Handle("/chat", http.StripPrefix("/chat", chatHandler))
s.router.Handle("/chat/*", http.StripPrefix("/chat", chatHandler))
}

func (s *Server) redirectToChat(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/chat/embed", http.StatusTemporaryRedirect)
rdir, err := url.JoinPath(s.chatBasePath, "embed")
if err != nil {
s.logger.Error("Failed to construct redirect URL", "error", err)
http.Error(w, "Failed to redirect", http.StatusInternalServerError)
return
}
http.Redirect(w, r, rdir, http.StatusTemporaryRedirect)
}
37 changes: 37 additions & 0 deletions lib/httpapi/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"sort"
"testing"
Expand Down Expand Up @@ -73,3 +75,38 @@ func TestOpenAPISchema(t *testing.T) {

require.Equal(t, currentSchema, diskSchema)
}

func TestServer_redirectToChat(t *testing.T) {
cases := []struct {
name string
chatBasePath string
expectedResponseCode int
expectedLocation string
}{
{"default base path", "/chat", http.StatusTemporaryRedirect, "/chat/embed"},
{"custom base path", "/custom", http.StatusTemporaryRedirect, "/custom/embed"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tCtx := logctx.WithLogger(context.Background(), slog.New(slog.NewTextHandler(os.Stdout, nil)))
s := httpapi.NewServer(tCtx, msgfmt.AgentTypeClaude, nil, 0, tc.chatBasePath)
tsServer := httptest.NewServer(s.Handler())
t.Cleanup(tsServer.Close)

client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Get(tsServer.URL + "/")
require.NoError(t, err, "unexpected error making GET request")
t.Cleanup(func() {
_ = resp.Body.Close()
})
require.Equal(t, tc.expectedResponseCode, resp.StatusCode, "expected %d status code", tc.expectedResponseCode)
loc := resp.Header.Get("Location")
require.Equal(t, tc.expectedLocation, loc, "expected Location %q, got %q", tc.expectedLocation, loc)
})
}
}