From 227ef9b14c2451c3c5579e6293c9ee46a4aa0311 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 30 Jul 2025 17:11:44 +0100 Subject: [PATCH 1/9] fix(chat): resolve relative agentAPI URL by default --- chat/src/components/chat-provider.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/chat/src/components/chat-provider.tsx b/chat/src/components/chat-provider.tsx index 2d5e1e7..9430c54 100644 --- a/chat/src/components/chat-provider.tsx +++ b/chat/src/components/chat-provider.tsx @@ -57,7 +57,8 @@ export function ChatProvider({ children }: PropsWithChildren) { const [serverStatus, setServerStatus] = useState("unknown"); const eventSourceRef = useRef(null); const searchParams = useSearchParams(); - const agentAPIUrl = searchParams.get("url") || window.location.origin; + const defaultAgentAPIURL = new URL("../../", window.location.href).toString(); + const agentAPIUrl = searchParams.get("url") || defaultAgentAPIURL; // Set up SSE connection to the events endpoint useEffect(() => { From ace9c6a06d1cc6fbaef972e40fcaa4260739d50c Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 30 Jul 2025 17:12:18 +0100 Subject: [PATCH 2/9] fix(lib/httpapi): redirect based on chatBasePath --- lib/httpapi/server.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/httpapi/server.go b/lib/httpapi/server.go index a343baa..ce73f90 100644 --- a/lib/httpapi/server.go +++ b/lib/httpapi/server.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "net/http" + "path/filepath" "sync" "time" @@ -33,6 +34,7 @@ type Server struct { agentio *termexec.Process agentType mf.AgentType emitter *EventEmitter + chatBasePath string } func (s *Server) GetOpenAPI() string { @@ -95,10 +97,11 @@ 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 } @@ -116,7 +119,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." @@ -158,7 +161,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 @@ -305,8 +308,8 @@ 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)) @@ -314,5 +317,5 @@ func (s *Server) registerStaticFileRoutes(chatBasePath string) { } func (s *Server) redirectToChat(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/chat/embed", http.StatusTemporaryRedirect) + http.Redirect(w, r, filepath.Join(s.chatBasePath, "embed"), http.StatusTemporaryRedirect) } From 60b542f09b82a4d2f46935d1df6da146336e3e4e Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 30 Jul 2025 17:25:25 +0100 Subject: [PATCH 3/9] fixup! fix(lib/httpapi): redirect based on chatBasePath --- lib/httpapi/server.go | 5 +++++ lib/httpapi/server_test.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/lib/httpapi/server.go b/lib/httpapi/server.go index ce73f90..d175bc4 100644 --- a/lib/httpapi/server.go +++ b/lib/httpapi/server.go @@ -106,6 +106,11 @@ func NewServer(ctx context.Context, agentType mf.AgentType, process *termexec.Pr return s } +// Router returns the underlying chi.Router for testing purposes. +func (s *Server) Router() http.Handler { + return s.router +} + func (s *Server) StartSnapshotLoop(ctx context.Context) { s.conversation.StartSnapshotLoop(ctx) go func() { diff --git a/lib/httpapi/server_test.go b/lib/httpapi/server_test.go index 456235a..be0d057 100644 --- a/lib/httpapi/server_test.go +++ b/lib/httpapi/server_test.go @@ -6,6 +6,8 @@ import ( "fmt" "io" "log/slog" + "net/http" + "net/http/httptest" "os" "sort" "testing" @@ -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.Router()) + 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) + }) + } +} From 9f6efb42151a666c9131d07373164513403e57b9 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 30 Jul 2025 17:37:09 +0100 Subject: [PATCH 4/9] fixup! fixup! fix(lib/httpapi): redirect based on chatBasePath --- lib/httpapi/server.go | 4 ++-- lib/httpapi/server_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/httpapi/server.go b/lib/httpapi/server.go index d175bc4..a9024a0 100644 --- a/lib/httpapi/server.go +++ b/lib/httpapi/server.go @@ -106,8 +106,8 @@ func NewServer(ctx context.Context, agentType mf.AgentType, process *termexec.Pr return s } -// Router returns the underlying chi.Router for testing purposes. -func (s *Server) Router() http.Handler { +// Handler returns the underlying chi.Router for testing purposes. +func (s *Server) Handler() http.Handler { return s.router } diff --git a/lib/httpapi/server_test.go b/lib/httpapi/server_test.go index be0d057..5abc5f5 100644 --- a/lib/httpapi/server_test.go +++ b/lib/httpapi/server_test.go @@ -91,7 +91,7 @@ func TestServer_redirectToChat(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.Router()) + tsServer := httptest.NewServer(s.Handler()) t.Cleanup(tsServer.Close) client := &http.Client{ From 7de9a8b6c59b78e35b4661db7802209daec5de21 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 30 Jul 2025 17:47:00 +0100 Subject: [PATCH 5/9] url.JoinPath --- lib/httpapi/server.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/httpapi/server.go b/lib/httpapi/server.go index a9024a0..1c37a11 100644 --- a/lib/httpapi/server.go +++ b/lib/httpapi/server.go @@ -6,7 +6,7 @@ import ( "fmt" "log/slog" "net/http" - "path/filepath" + "net/url" "sync" "time" @@ -322,5 +322,10 @@ func (s *Server) registerStaticFileRoutes() { } func (s *Server) redirectToChat(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, filepath.Join(s.chatBasePath, "embed"), http.StatusTemporaryRedirect) + rdir, err := url.JoinPath(s.chatBasePath, "embed") + if err != nil { + http.Error(w, "Failed to redirect", http.StatusInternalServerError) + return + } + http.Redirect(w, r, rdir, http.StatusTemporaryRedirect) } From b1a19048f1e28d86d3651767fcc49937584c2546 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 30 Jul 2025 17:47:55 +0100 Subject: [PATCH 6/9] fixup! url.JoinPath --- lib/httpapi/server.go | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/httpapi/server.go b/lib/httpapi/server.go index 1c37a11..a76cf68 100644 --- a/lib/httpapi/server.go +++ b/lib/httpapi/server.go @@ -324,6 +324,7 @@ func (s *Server) registerStaticFileRoutes() { func (s *Server) redirectToChat(w http.ResponseWriter, r *http.Request) { 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 } From 07eee1056b698b9166056beae9fd542df63c7cdc Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 31 Jul 2025 09:00:01 +0100 Subject: [PATCH 7/9] comment --- chat/src/components/chat-provider.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/chat/src/components/chat-provider.tsx b/chat/src/components/chat-provider.tsx index 9430c54..0cbb77f 100644 --- a/chat/src/components/chat-provider.tsx +++ b/chat/src/components/chat-provider.tsx @@ -57,6 +57,9 @@ export function ChatProvider({ children }: PropsWithChildren) { const [serverStatus, setServerStatus] = useState("unknown"); const eventSourceRef = useRef(null); const searchParams = useSearchParams(); + // NOTE(cian): When hosting this application on a subpath, we need to + // ensure that the agent API URL is correctly set. Refer to + // https://github.com/coder/coder/issues/18779#issuecomment-3133290494 const defaultAgentAPIURL = new URL("../../", window.location.href).toString(); const agentAPIUrl = searchParams.get("url") || defaultAgentAPIURL; From 01d97daacf94a4bf8ebcfc54e7ceb134625846ab Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 31 Jul 2025 09:11:55 +0100 Subject: [PATCH 8/9] verboser comment --- chat/src/components/chat-provider.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/chat/src/components/chat-provider.tsx b/chat/src/components/chat-provider.tsx index 0cbb77f..db9754e 100644 --- a/chat/src/components/chat-provider.tsx +++ b/chat/src/components/chat-provider.tsx @@ -57,9 +57,16 @@ export function ChatProvider({ children }: PropsWithChildren) { const [serverStatus, setServerStatus] = useState("unknown"); const eventSourceRef = useRef(null); const searchParams = useSearchParams(); - // NOTE(cian): When hosting this application on a subpath, we need to - // ensure that the agent API URL is correctly set. Refer to - // https://github.com/coder/coder/issues/18779#issuecomment-3133290494 + // 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(); const agentAPIUrl = searchParams.get("url") || defaultAgentAPIURL; From 1a5500f0cb0136e6d3048a05221913c1066ad19f Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Thu, 31 Jul 2025 13:15:47 +0200 Subject: [PATCH 9/9] fix: suggest changes to cj/fix/chat-base-path (#42) Co-authored-by: Cian Johnston --- Makefile | 2 +- chat/next.config.ts | 5 +++- chat/src/components/chat-provider.tsx | 41 +++++++++++++++++++++------ lib/httpapi/server.go | 3 +- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/Makefile b/Makefile index 95c69fe..745d9b9 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ BASE_PATH ?= /magic-base-path-placeholder $(CHAT_SOURCES_STAMP): $(CHAT_SOURCES) @echo "Chat sources changed. Running build steps..." - cd chat && BASE_PATH=${BASE_PATH} bun run build + cd chat && NEXT_PUBLIC_BASE_PATH="${BASE_PATH}" bun run build rm -rf lib/httpapi/chat && mkdir -p lib/httpapi/chat && touch lib/httpapi/chat/marker cp -r chat/out/. lib/httpapi/chat/ touch $@ diff --git a/chat/next.config.ts b/chat/next.config.ts index baf9e51..358d829 100644 --- a/chat/next.config.ts +++ b/chat/next.config.ts @@ -1,5 +1,8 @@ import type { NextConfig } from "next"; -const basePath = process.env.BASE_PATH ?? "/chat"; +let basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "/chat"; +if (basePath.endsWith("/")) { + basePath = basePath.slice(0, -1); +} const nextConfig: NextConfig = { // Enable static exports diff --git a/chat/src/components/chat-provider.tsx b/chat/src/components/chat-provider.tsx index db9754e..d57c3c8 100644 --- a/chat/src/components/chat-provider.tsx +++ b/chat/src/components/chat-provider.tsx @@ -51,14 +51,20 @@ interface ChatContextValue { const ChatContext = createContext(undefined); -export function ChatProvider({ children }: PropsWithChildren) { - const [messages, setMessages] = useState<(Message | DraftMessage)[]>([]); - const [loading, setLoading] = useState(false); - const [serverStatus, setServerStatus] = useState("unknown"); - const eventSourceRef = useRef(null); +const useAgentAPIUrl = (): string => { const searchParams = useSearchParams(); - // 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 + const paramsUrl = searchParams.get("url"); + if (paramsUrl) { + return paramsUrl; + } + const basePath = process.env.NEXT_PUBLIC_BASE_PATH; + if (!basePath) { + throw new Error( + "agentAPIUrl is not set. Please set the url query parameter to the URL of the AgentAPI or the NEXT_PUBLIC_BASE_PATH environment variable." + ); + } + // NOTE(cian): We use '../' here to construct the agent API URL relative + // to the chat's 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. @@ -67,8 +73,25 @@ export function ChatProvider({ children }: PropsWithChildren) { // `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(); - const agentAPIUrl = searchParams.get("url") || defaultAgentAPIURL; + let chatURL: string = new URL(basePath, window.location.origin).toString(); + // NOTE: trailing slashes and relative URLs are tricky. + // https://developer.mozilla.org/en-US/docs/Web/API/URL_API/Resolving_relative_references#current_directory_relative + if (!chatURL.endsWith("/")) { + chatURL += "/"; + } + const agentAPIURL = new URL("..", chatURL).toString(); + if (agentAPIURL.endsWith("/")) { + return agentAPIURL.slice(0, -1); + } + return agentAPIURL; +}; + +export function ChatProvider({ children }: PropsWithChildren) { + const [messages, setMessages] = useState<(Message | DraftMessage)[]>([]); + const [loading, setLoading] = useState(false); + const [serverStatus, setServerStatus] = useState("unknown"); + const eventSourceRef = useRef(null); + const agentAPIUrl = useAgentAPIUrl(); // Set up SSE connection to the events endpoint useEffect(() => { diff --git a/lib/httpapi/server.go b/lib/httpapi/server.go index a76cf68..3d8c710 100644 --- a/lib/httpapi/server.go +++ b/lib/httpapi/server.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" "net/url" + "strings" "sync" "time" @@ -97,7 +98,7 @@ func NewServer(ctx context.Context, agentType mf.AgentType, process *termexec.Pr agentio: process, agentType: agentType, emitter: emitter, - chatBasePath: chatBasePath, + chatBasePath: strings.TrimSuffix(chatBasePath, "/"), } // Register API routes