diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go
index a499c1ea2..5ef3ba2c5 100644
--- a/web/backend/api/gateway_host.go
+++ b/web/backend/api/gateway_host.go
@@ -57,10 +57,28 @@ func requestHostName(r *http.Request) string {
return "127.0.0.1"
}
+func requestWSScheme(r *http.Request) string {
+ if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" {
+ proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0]))
+ if proto == "https" || proto == "wss" {
+ return "wss"
+ }
+ if proto == "http" || proto == "ws" {
+ return "ws"
+ }
+ }
+
+ if r.TLS != nil {
+ return "wss"
+ }
+
+ return "ws"
+}
+
func (h *Handler) buildWsURL(r *http.Request, cfg *config.Config) string {
host := h.effectiveGatewayBindHost(cfg)
if host == "" || host == "0.0.0.0" {
host = requestHostName(r)
}
- return "ws://" + net.JoinHostPort(host, strconv.Itoa(cfg.Gateway.Port)) + "/pico/ws"
+ return requestWSScheme(r) + "://" + net.JoinHostPort(host, strconv.Itoa(cfg.Gateway.Port)) + "/pico/ws"
}
diff --git a/web/backend/api/gateway_host_test.go b/web/backend/api/gateway_host_test.go
index afd600359..43e84ff0e 100644
--- a/web/backend/api/gateway_host_test.go
+++ b/web/backend/api/gateway_host_test.go
@@ -1,6 +1,7 @@
package api
import (
+ "crypto/tls"
"net/http/httptest"
"path/filepath"
"testing"
@@ -57,3 +58,55 @@ func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) {
t.Fatalf("gatewayProbeHost() = %q, want %q", got, "127.0.0.1")
}
}
+
+func TestBuildWsURLUsesWSSWhenForwardedProtoIsHTTPS(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+
+ cfg := config.DefaultConfig()
+ cfg.Gateway.Host = "0.0.0.0"
+ cfg.Gateway.Port = 18790
+
+ req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil)
+ req.Host = "chat.example.com"
+ req.Header.Set("X-Forwarded-Proto", "https")
+
+ if got := h.buildWsURL(req, cfg); got != "wss://chat.example.com:18790/pico/ws" {
+ t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:18790/pico/ws")
+ }
+}
+
+func TestBuildWsURLUsesWSSWhenRequestIsTLS(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+
+ cfg := config.DefaultConfig()
+ cfg.Gateway.Host = "0.0.0.0"
+ cfg.Gateway.Port = 18790
+
+ req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil)
+ req.Host = "secure.example.com"
+ req.TLS = &tls.ConnectionState{}
+
+ if got := h.buildWsURL(req, cfg); got != "wss://secure.example.com:18790/pico/ws" {
+ t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:18790/pico/ws")
+ }
+}
+
+func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ h := NewHandler(configPath)
+
+ cfg := config.DefaultConfig()
+ cfg.Gateway.Host = "0.0.0.0"
+ cfg.Gateway.Port = 18790
+
+ req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil)
+ req.Host = "chat.example.com"
+ req.TLS = &tls.ConnectionState{}
+ req.Header.Set("X-Forwarded-Proto", "http")
+
+ if got := h.buildWsURL(req, cfg); got != "ws://chat.example.com:18790/pico/ws" {
+ t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:18790/pico/ws")
+ }
+}
diff --git a/web/frontend/src/components/chat/chat-composer.tsx b/web/frontend/src/components/chat/chat-composer.tsx
index e8bae89b8..7d696b898 100644
--- a/web/frontend/src/components/chat/chat-composer.tsx
+++ b/web/frontend/src/components/chat/chat-composer.tsx
@@ -42,7 +42,7 @@ export function ChatComposer({
placeholder={t("chat.placeholder")}
disabled={!canInput}
className={cn(
- "max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent",
+ "placeholder:text-muted-foreground max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent",
!canInput && "cursor-not-allowed",
)}
minRows={1}
@@ -56,7 +56,7 @@ export function ChatComposer({
size="icon"
className="size-8 rounded-full bg-violet-500 text-white transition-transform hover:bg-violet-600 active:scale-95"
onClick={onSend}
- disabled={!input.trim() || !isConnected}
+ disabled={!input.trim() || !canInput}
>
diff --git a/web/frontend/src/components/chat/chat-empty-state.tsx b/web/frontend/src/components/chat/chat-empty-state.tsx
index 624ff9c59..0574c44d1 100644
--- a/web/frontend/src/components/chat/chat-empty-state.tsx
+++ b/web/frontend/src/components/chat/chat-empty-state.tsx
@@ -34,7 +34,7 @@ export function ChatEmptyState({
{t("chat.empty.noConfiguredModelDescription")}
-