From 398ae1af86c1f11d354353759ccab5ccf2d2e15d Mon Sep 17 00:00:00 2001 From: Kevin Bravo Date: Wed, 26 Aug 2026 12:48:25 -0400 Subject: [PATCH 1/2] feat: add Oracle relay deployment and Hermes control bridging - Add production Oracle deployment stack and documentation - Bridge Hermes control endpoints with native Mobile compatibility - Prevent active connector status probes from displacing tunnels --- README.md | 11 +- apps/connect/internal/cli/service_linux.go | 6 +- .../internal/cli/service_linux_test.go | 2 +- apps/connect/internal/cli/status.go | 13 ++ apps/connect/internal/cli/status_test.go | 14 ++ apps/connect/internal/hermes/client.go | 137 +++++++++++++ apps/connect/internal/hermes/client_test.go | 100 ++++++++- apps/mobile/package-lock.json | 45 ++++ apps/mobile/src/app/explore.tsx | 6 +- .../src/features/home/hermes-home-screen.tsx | 6 +- .../features/threads/hermes-thread-screen.tsx | 15 +- apps/mobile/src/lib/brio.test.mjs | 98 ++++++++- apps/mobile/src/lib/brio.ts | 194 +++++++++++++++--- apps/mobile/src/state/composer-store-model.ts | 2 + deploy/oracle/.env.example | 26 +++ deploy/oracle/Caddyfile | 4 + deploy/oracle/README.md | 26 +++ deploy/oracle/docker-compose.yml | 57 +++++ go.work.sum | 15 +- 19 files changed, 706 insertions(+), 71 deletions(-) create mode 100644 apps/connect/internal/cli/status_test.go create mode 100644 deploy/oracle/.env.example create mode 100644 deploy/oracle/Caddyfile create mode 100644 deploy/oracle/README.md create mode 100644 deploy/oracle/docker-compose.yml diff --git a/README.md b/README.md index 6a3535a..e7f3873 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,7 @@ Common values: - `BRIO_RELAY_TRUSTED_PROXY_CIDRS` - comma-separated reverse-proxy networks allowed to supply `X-Forwarded-For` for rate limiting. Forwarded addresses are ignored unless the direct TCP peer matches this list. - Production requests outside loopback require HTTPS/WSS. TLS-terminating proxies must be listed in `BRIO_RELAY_TRUSTED_PROXY_CIDRS` and send `X-Forwarded-Proto: https`; plaintext `/health` remains available for load-balancer probes. - Current Mobile and connector clients also reject remote plaintext relay URLs and relay HTTP redirects before sending enrollment codes or long-lived credentials. Plain HTTP remains available only for explicit loopback development URLs. -- `BRIO_RELAY_ALLOWED_ORIGINS` - comma-separated browser origins allowed for relay CORS and WebSocket upgrades. Browser origins are denied when this is unset unless insecure development mode is explicitly enabled; origin-less native clients continue to work. +- `BRIO_RELAY_ALLOWED_ORIGINS` - comma-separated origins allowed for relay CORS and WebSocket upgrades. Include the relay's own public HTTPS origin for React Native Android, which sends that origin by default, plus any separate browser app origins. Origins are denied when this is unset unless insecure development mode is explicitly enabled. - `BRIO_CLERK_SECRET_KEY` or `BRIO_CLERK_JWT_KEY` - enables verified Clerk identity on `POST /auth/devices`. Also set the exact `BRIO_CLERK_ISSUER` and `BRIO_CLERK_JWT_AUDIENCE`; `BRIO_CLERK_AUTHORIZED_PARTIES` optionally restricts the Clerk `azp` claim. The matching Mobile build uses `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY`, `EXPO_PUBLIC_CLERK_JWT_TEMPLATE`, and `EXPO_PUBLIC_BRIO_RELAY_URL`. - `BRIO_DEVICE_REGISTRATION_KEY` - operations-only fallback for `POST /auth/devices` through `Authorization: Bearer ...` or `X-Brio-Registration-Key`. With neither verified identity, this key, nor insecure development mode configured, device registration fails closed. Never embed this key in a public app build. - `EXPO_PUBLIC_BRIO_DEV_AUTH` - exposes Mobile's unverified email form for local development. Production builds leave this unset and use the configured Clerk account flow. @@ -338,8 +338,9 @@ above). The mobile app includes the same recovery flow. ## Deployment -The relay ships as a Docker image (`apps/relay/Dockerfile`), and an AWS Copilot -manifest lives under `copilot/`. No production relay URL is embedded in Mobile -or the installer: deploy the service, configure Postgres and verified identity, -then provide that HTTPS URL through `EXPO_PUBLIC_BRIO_RELAY_URL` and generated +The relay ships as a Docker image (`apps/relay/Dockerfile`). The current Oracle +deployment stack lives under `deploy/oracle`, while the AWS Copilot manifest +remains under `copilot/`. No production relay URL is embedded in Mobile or the +installer: deploy the service, configure Postgres and verified identity, then +provide its HTTPS URL through `EXPO_PUBLIC_BRIO_RELAY_URL` and generated enrollment commands. diff --git a/apps/connect/internal/cli/service_linux.go b/apps/connect/internal/cli/service_linux.go index 094a8f7..70c737c 100644 --- a/apps/connect/internal/cli/service_linux.go +++ b/apps/connect/internal/cli/service_linux.go @@ -43,7 +43,7 @@ func installService(exe string, startNow bool) error { return runCommand("systemctl", "--user", "enable", serviceName+".service") } -func renderSystemdUserService(exe string, home string) string { +func renderSystemdUserService(exe string, _ string) string { return fmt.Sprintf(`[Unit] Description=Brio Connector After=network-online.target @@ -53,7 +53,7 @@ StartLimitBurst=5 [Service] Type=simple ExecStart=%s connect -WorkingDirectory=%s +WorkingDirectory=%%h # Hermes CLI subprocesses share this cgroup. If the kernel kills one greedy # child, keep the connector alive and let that request fail in isolation. OOMPolicy=continue @@ -62,7 +62,7 @@ RestartSec=5 [Install] WantedBy=default.target -`, systemdQuote(exe), systemdQuote(home)) +`, systemdQuote(exe)) } func stopService() error { diff --git a/apps/connect/internal/cli/service_linux_test.go b/apps/connect/internal/cli/service_linux_test.go index 351bfaa..3bc04a0 100644 --- a/apps/connect/internal/cli/service_linux_test.go +++ b/apps/connect/internal/cli/service_linux_test.go @@ -16,7 +16,7 @@ func TestRenderSystemdUserServiceBoundsCrashLoopsAndChildOOMs(t *testing.T) { "Restart=always", "RestartSec=5", `ExecStart="/opt/Brio Connector/brio" connect`, - `WorkingDirectory="/home/brio user"`, + `WorkingDirectory=%h`, } { if !strings.Contains(unit, expected) { t.Fatalf("systemd unit omitted %q:\n%s", expected, unit) diff --git a/apps/connect/internal/cli/status.go b/apps/connect/internal/cli/status.go index 07d2e03..9afe59a 100644 --- a/apps/connect/internal/cli/status.go +++ b/apps/connect/internal/cli/status.go @@ -36,9 +36,11 @@ func runStatus(ctx context.Context, opts runOptions) error { fmt.Printf("Relay URL: %s\n", opts.relayURL) } + serviceState := "" if status, err := serviceStatus(); err != nil { fmt.Printf("Service: unknown (%v)\n", err) } else { + serviceState = status fmt.Printf("Service: %s\n", status) } @@ -58,6 +60,13 @@ func runStatus(ctx context.Context, opts runOptions) error { fmt.Println("Tunnel: no stored credentials (run `brio setup` or `brio recover`)") return nil } + // A companion probe authenticates by opening a real tunnel. The relay permits + // one companion per agent, so probing while the service is active would + // replace the production tunnel and cause a visible reconnect in Mobile. + if !shouldProbeTunnel(serviceState) { + fmt.Println("Tunnel: service active (credential probe skipped)") + return nil + } if err := tunnel.Probe(statusCtx, tunnel.Config{ AgentID: opts.agentID, RelayURL: opts.relayURL, @@ -70,6 +79,10 @@ func runStatus(ctx context.Context, opts runOptions) error { return nil } +func shouldProbeTunnel(serviceState string) bool { + return serviceState != "active" +} + func checkRelayHealth(ctx context.Context, relayURL string) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, relayURL+"/health", nil) if err != nil { diff --git a/apps/connect/internal/cli/status_test.go b/apps/connect/internal/cli/status_test.go new file mode 100644 index 0000000..5a4d4ae --- /dev/null +++ b/apps/connect/internal/cli/status_test.go @@ -0,0 +1,14 @@ +package cli + +import "testing" + +func TestShouldProbeTunnelSkipsActiveService(t *testing.T) { + if shouldProbeTunnel("active") { + t.Fatal("active service must not be displaced by a credential probe") + } + for _, state := range []string{"", "inactive", "failed", "unknown"} { + if !shouldProbeTunnel(state) { + t.Fatalf("service state %q should allow a credential probe", state) + } + } +} diff --git a/apps/connect/internal/hermes/client.go b/apps/connect/internal/hermes/client.go index 258a7ff..37985a5 100644 --- a/apps/connect/internal/hermes/client.go +++ b/apps/connect/internal/hermes/client.go @@ -11,6 +11,7 @@ import ( "bufio" "bytes" "context" + "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -78,6 +79,9 @@ const ( RouteUnknown RouteKind = iota // RouteForward means the path is proxied to the Hermes API server. RouteForward + // RouteControlForward means the path is proxied to the authenticated + // Hermes dashboard/control HTTP server. + RouteControlForward // RouteLocal means the path is served from the Hermes home directory. RouteLocal ) @@ -141,8 +145,30 @@ func RoutePath(path string) Route { return Route{Kind: RouteLocal, Path: path, Name: "composer-attachments"} case "/api/sessions": return Route{Kind: RouteForward, Path: path} + case "/api/sessions/search": + return Route{Kind: RouteControlForward, Path: path} case "/api/model/options": return Route{Kind: RouteForward, Path: path} + case "/files": + return Route{Kind: RouteControlForward, Path: "/api/files"} + case "/files/read": + return Route{Kind: RouteControlForward, Path: "/api/files/read"} + case "/files/write": + return Route{Kind: RouteControlForward, Path: "/api/files/upload"} + case "/config/raw": + return Route{Kind: RouteControlForward, Path: "/api/config/raw"} + case "/skills": + return Route{Kind: RouteControlForward, Path: "/api/skills"} + case "/tools/toolsets": + return Route{Kind: RouteControlForward, Path: "/api/tools/toolsets"} + case "/gateway/status": + return Route{Kind: RouteControlForward, Path: "/api/status"} + case "/gateway/restart": + return Route{Kind: RouteControlForward, Path: "/api/gateway/restart"} + case "/logs": + return Route{Kind: RouteControlForward, Path: "/api/logs"} + case "/jobs", "/jobs/": + return Route{Kind: RouteControlForward, Path: "/api/cron/jobs"} } switch { case strings.HasPrefix(path, "/api/profiles/"): @@ -151,6 +177,15 @@ func RoutePath(path string) Route { return Route{Kind: RouteLocal, Path: path, Name: "composer-attachment"} case strings.HasPrefix(path, "/v1/runs"), strings.HasPrefix(path, "/api/jobs"): return Route{Kind: RouteForward, Path: path} + case strings.HasPrefix(path, "/tools/toolsets/"): + name := strings.TrimPrefix(path, "/tools/toolsets/") + if name != "" && !strings.Contains(name, "/") { + return Route{Kind: RouteControlForward, Path: "/api/tools/toolsets/" + name} + } + case strings.HasPrefix(path, "/jobs/"): + if mapped, ok := legacyCronJobPath(path); ok { + return Route{Kind: RouteControlForward, Path: mapped} + } } if isSessionMessagesPath(path) || isSessionDetailPath(path) || isSessionModelPath(path) { return Route{Kind: RouteForward, Path: path} @@ -158,6 +193,23 @@ func RoutePath(path string) Route { return Route{Kind: RouteUnknown} } +func legacyCronJobPath(path string) (string, bool) { + rest := strings.TrimPrefix(path, "/jobs/") + id, action, hasAction := strings.Cut(rest, "/") + if id == "" || strings.Contains(action, "/") { + return "", false + } + if !hasAction { + return "/api/cron/jobs/" + id, true + } + switch action { + case "pause", "resume", "trigger": + return "/api/cron/jobs/" + id + "/" + action, true + default: + return "", false + } +} + // splitProfilePrefix recognizes `/p/` and `/p//...` prefixes for // NAMED profiles. The remaining path keeps its leading slash so it can be // routed exactly like an unprefixed request. `default` is rejected as a @@ -316,6 +368,8 @@ func (c *Client) Serve(ctx context.Context, frame tunnel.Frame, emit func(tunnel switch route.Kind { case RouteForward: return c.forward(ctx, frame, method, route, query, emit) + case RouteControlForward: + return c.forwardControlHTTP(ctx, frame, method, route, query, emit) case RouteLocal: return c.serveLocal(ctx, frame, method, route, query, emit) default: @@ -323,6 +377,89 @@ func (c *Client) Serve(ctx context.Context, frame tunnel.Frame, emit func(tunnel } } +// forwardControlHTTP serves dashboard-only JSON endpoints that the Hermes +// gateway API does not expose. The control token is always injected locally; +// credentials supplied by Mobile are never forwarded. +func (c *Client) forwardControlHTTP(ctx context.Context, frame tunnel.Frame, method string, route Route, query string, emit func(tunnel.Frame) error) error { + requestMethod := method + requestBody := frame.Body + if route.Path == "/api/files/upload" && method == http.MethodPut { + body, ok := frame.Body.(map[string]any) + if !ok { + return emit(errorFrame(frame.ID, "BAD_REQUEST", "file write body must be an object")) + } + path, pathOK := body["path"].(string) + content, contentOK := body["content"].(string) + if !pathOK || strings.TrimSpace(path) == "" || !contentOK { + return emit(errorFrame(frame.ID, "BAD_REQUEST", "file write requires path and content")) + } + if len(content) > 1024*1024 { + return emit(errorFrame(frame.ID, "BAD_REQUEST", "file content exceeds 1 MiB")) + } + requestMethod = http.MethodPost + requestBody = map[string]any{ + "path": path, + "data_url": "data:text/plain;base64," + base64.StdEncoding.EncodeToString([]byte(content)), + "overwrite": true, + } + } + if strings.HasPrefix(route.Path, "/api/tools/toolsets/") && method == http.MethodPatch { + requestMethod = http.MethodPut + } + endpoint, err := c.gatewayEndpoint(route.Profile) + if err != nil { + return emit(errorFrame(frame.ID, "CONTROL_UNAVAILABLE", err.Error())) + } + target := strings.TrimRight(endpoint.BaseURL, "/") + route.Path + if query != "" { + target += "?" + query + } + var bodyReader io.Reader = http.NoBody + if requestBody != nil { + payload, marshalErr := json.Marshal(requestBody) + if marshalErr != nil { + return emit(errorFrame(frame.ID, "BAD_REQUEST", marshalErr.Error())) + } + bodyReader = bytes.NewReader(payload) + } + req, err := http.NewRequestWithContext(ctx, requestMethod, target, bodyReader) + if err != nil { + return emit(errorFrame(frame.ID, "BAD_REQUEST", err.Error())) + } + req.Header.Set("Accept", "application/json") + if requestBody != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Authorization", "Bearer "+endpoint.Token) + resp, err := c.httpClient().Do(req) + if err != nil { + return emit(errorFrame(frame.ID, "LOCAL_UNREACHABLE", err.Error())) + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1)) + if err != nil { + return emit(errorFrame(frame.ID, "LOCAL_READ_FAILED", err.Error())) + } + if len(data) > maxResponseBytes { + return emit(errorFrame(frame.ID, "RESPONSE_TOO_LARGE", "local response is larger than 10 MiB")) + } + contentType := resp.Header.Get("Content-Type") + var body any + if len(data) > 0 && strings.Contains(strings.ToLower(contentType), "json") { + _ = json.Unmarshal(data, &body) + } + if body == nil { + body = string(data) + } + return emit(tunnel.Frame{ + Type: "response", + ID: frame.ID, + Status: resp.StatusCode, + Headers: map[string]string{"Content-Type": contentType}, + Body: body, + }) +} + func (c *Client) forward(ctx context.Context, frame tunnel.Frame, method string, route Route, query string, emit func(tunnel.Frame) error) error { if route.Path == "/v1/responses" && method == http.MethodPost { prepared, failure := c.prepareResponseRequest(ctx, frame.Body) diff --git a/apps/connect/internal/hermes/client_test.go b/apps/connect/internal/hermes/client_test.go index 48e688d..92f4a01 100644 --- a/apps/connect/internal/hermes/client_test.go +++ b/apps/connect/internal/hermes/client_test.go @@ -56,10 +56,23 @@ func TestRoutePath(t *testing.T) { {path: "/api/jobs/job_1/pause", kind: RouteForward, forwardTo: "/api/jobs/job_1/pause"}, {path: "/api/sessions", kind: RouteForward, forwardTo: "/api/sessions"}, {path: "/api/sessions/sess_1", kind: RouteForward, forwardTo: "/api/sessions/sess_1"}, - {path: "/api/sessions/search", kind: RouteForward, forwardTo: "/api/sessions/search"}, + {path: "/api/sessions/search", kind: RouteControlForward, forwardTo: "/api/sessions/search"}, {path: "/api/sessions/sess_1/messages", kind: RouteForward, forwardTo: "/api/sessions/sess_1/messages"}, {path: "/api/sessions/sess_1/model", kind: RouteForward, forwardTo: "/api/sessions/sess_1/model"}, {path: "/api/model/options", kind: RouteForward, forwardTo: "/api/model/options"}, + {path: "/files", kind: RouteControlForward, forwardTo: "/api/files"}, + {path: "/files/read", kind: RouteControlForward, forwardTo: "/api/files/read"}, + {path: "/files/write", kind: RouteControlForward, forwardTo: "/api/files/upload"}, + {path: "/config/raw", kind: RouteControlForward, forwardTo: "/api/config/raw"}, + {path: "/skills", kind: RouteControlForward, forwardTo: "/api/skills"}, + {path: "/tools/toolsets", kind: RouteControlForward, forwardTo: "/api/tools/toolsets"}, + {path: "/tools/toolsets/browser", kind: RouteControlForward, forwardTo: "/api/tools/toolsets/browser"}, + {path: "/gateway/status", kind: RouteControlForward, forwardTo: "/api/status"}, + {path: "/gateway/restart", kind: RouteControlForward, forwardTo: "/api/gateway/restart"}, + {path: "/logs", kind: RouteControlForward, forwardTo: "/api/logs"}, + {path: "/jobs/", kind: RouteControlForward, forwardTo: "/api/cron/jobs"}, + {path: "/jobs/job_1/pause", kind: RouteControlForward, forwardTo: "/api/cron/jobs/job_1/pause"}, + {path: "/jobs/job_1", kind: RouteControlForward, forwardTo: "/api/cron/jobs/job_1"}, {path: "/v1/memory", kind: RouteLocal, localName: "memory"}, {path: "/memory", kind: RouteLocal, localName: "memory"}, {path: "/composer/capabilities", kind: RouteLocal, localName: "composer-capabilities"}, @@ -69,14 +82,6 @@ func TestRoutePath(t *testing.T) { {path: "/attachments", kind: RouteLocal, forwardTo: "/attachments", localName: "composer-attachments"}, {path: "/attachments/0123456789abcdef0123456789abcdef/chunks/0", kind: RouteLocal, forwardTo: "/attachments/0123456789abcdef0123456789abcdef/chunks/0", localName: "composer-attachment"}, - {path: "/files/write", kind: RouteUnknown}, - {path: "/files/read", kind: RouteUnknown}, - {path: "/config/raw", kind: RouteUnknown}, - {path: "/gateway/restart", kind: RouteUnknown}, - {path: "/gateway/status", kind: RouteUnknown}, - {path: "/skills", kind: RouteUnknown}, - {path: "/tools/toolsets", kind: RouteUnknown}, - {path: "/logs", kind: RouteUnknown}, {path: "/sessions/search", kind: RouteUnknown}, {path: "/v1/sessions/search", kind: RouteUnknown}, {path: "/v1/sessions/sess_1", kind: RouteUnknown}, @@ -104,6 +109,81 @@ func TestRoutePath(t *testing.T) { } } +func TestSessionSearchUsesControlServerCredentials(t *testing.T) { + var gotAuth, gotPath, gotQuery string + control := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}}) + })) + defer control.Close() + + client := &Client{ + BaseURL: "http://127.0.0.1:1", + APIKey: "gateway-key", + ControlBaseURL: control.URL, + ControlToken: "control-key", + Home: t.TempDir(), + } + frames := collectFrames(context.Background(), t, client, tunnel.Frame{ + Type: "request", ID: "search-1", Method: http.MethodGet, + Path: "/api/sessions/search?q=needle&limit=10", + Headers: map[string]string{"Authorization": "Bearer mobile-token"}, + }) + if gotAuth != "Bearer control-key" { + t.Fatalf("Authorization = %q, want control credential", gotAuth) + } + if gotPath != "/api/sessions/search" || gotQuery != "q=needle&limit=10" { + t.Fatalf("request = %s?%s", gotPath, gotQuery) + } + if len(frames) != 1 || frames[0].Status != http.StatusOK { + t.Fatalf("frames = %+v, want one 200 response", frames) + } +} + +func TestControlCompatibilityRoutesTranslateLegacyWrites(t *testing.T) { + type seenRequest struct { + method string + path string + body map[string]any + } + seen := []seenRequest{} + control := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if r.Body != nil { + _ = json.NewDecoder(r.Body).Decode(&body) + } + seen = append(seen, seenRequest{method: r.Method, path: r.URL.Path, body: body}) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) + })) + defer control.Close() + client := &Client{ControlBaseURL: control.URL, ControlToken: "control-key", Home: t.TempDir()} + + collectFrames(context.Background(), t, client, tunnel.Frame{ + Type: "request", ID: "write", Method: http.MethodPut, Path: "/files/write", + Body: map[string]any{"path": "/workspace/note.txt", "content": "hello"}, + }) + collectFrames(context.Background(), t, client, tunnel.Frame{ + Type: "request", ID: "toolset", Method: http.MethodPatch, Path: "/tools/toolsets/browser", + Body: map[string]any{"enabled": true, "platform": "cli"}, + }) + + if len(seen) != 2 { + t.Fatalf("seen = %+v", seen) + } + if seen[0].method != http.MethodPost || seen[0].path != "/api/files/upload" || + seen[0].body["path"] != "/workspace/note.txt" || + seen[0].body["data_url"] != "data:text/plain;base64,aGVsbG8=" { + t.Fatalf("file write translation = %+v", seen[0]) + } + if seen[1].method != http.MethodPut || seen[1].path != "/api/tools/toolsets/browser" || seen[1].body["enabled"] != true { + t.Fatalf("toolset translation = %+v", seen[1]) + } +} + func TestServeReplacesAuthorizationWithHermesKey(t *testing.T) { var gotAuth, gotPath string hermes := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -192,7 +272,7 @@ func TestServeMapsLegacyAliases(t *testing.T) { func TestServeRejectsUnknownPath(t *testing.T) { client := &Client{BaseURL: "http://127.0.0.1:1", APIKey: "hermes-key", Home: t.TempDir()} - for _, path := range []string{"/files/write", "/config/raw", "/gateway/restart", "/skills", "/tools/toolsets", "/logs"} { + for _, path := range []string{"/files/delete-all", "/config/secrets", "/gateway/shell", "/skills/install", "/tools/toolsets/a/b", "/logs/delete"} { frames := collectFrames(context.Background(), t, client, tunnel.Frame{ Type: "request", ID: "u1", Method: http.MethodGet, Path: path, }) diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index ca3e0f1..7b3612a 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -1353,6 +1353,17 @@ "node": ">=0.8.0" } }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -5693,6 +5704,40 @@ "node": ">=14.0.0" } }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", diff --git a/apps/mobile/src/app/explore.tsx b/apps/mobile/src/app/explore.tsx index 340d98a..a8303cd 100644 --- a/apps/mobile/src/app/explore.tsx +++ b/apps/mobile/src/app/explore.tsx @@ -3,7 +3,7 @@ import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from 'reac import { SafeAreaView } from 'react-native-safe-area-context'; import { DashboardCard, SectionLabel, StatusBadge } from '@/components/dashboard'; -import { brioFetch, getCapabilities, getHealth } from '@/lib/brio'; +import { getCapabilities, getHealth, getMemory, listSessions } from '@/lib/brio'; import { ThemedText } from '@/components/themed-text'; import { ThemedView } from '@/components/themed-view'; import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme'; @@ -26,12 +26,12 @@ export default function ManageScreen() { }); const sessions = useQuery({ queryKey: ['sessions', connection?.url], - queryFn: () => brioFetch<{ sessions: unknown[] }>(connection!, '/sessions?limit=5'), + queryFn: () => listSessions(connection!, 5), enabled: Boolean(connection), }); const memory = useQuery({ queryKey: ['memory', connection?.url], - queryFn: () => brioFetch<{ memory: string; user: string }>(connection!, '/memory'), + queryFn: () => getMemory(connection!), enabled: Boolean(connection), }); diff --git a/apps/mobile/src/features/home/hermes-home-screen.tsx b/apps/mobile/src/features/home/hermes-home-screen.tsx index e553e1b..d8719d1 100644 --- a/apps/mobile/src/features/home/hermes-home-screen.tsx +++ b/apps/mobile/src/features/home/hermes-home-screen.tsx @@ -123,8 +123,10 @@ export function HermesHomeScreen({ connection }: { connection: AgentConnection } isNamedProfile(activeProfile) ? `?profile=${encodeURIComponent(activeProfile)}` : '' }` as const; const startNewTask = () => { - const sessionId = `brio_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - router.push(threadPath(sessionId)); + // `new` tells the thread screen to create a Hermes gateway session on the + // first prompt. A made-up stored session id is treated as a resume request + // by Hermes and leaves a fresh conversation in an error state. + router.push(threadPath('new')); }; return ( diff --git a/apps/mobile/src/features/threads/hermes-thread-screen.tsx b/apps/mobile/src/features/threads/hermes-thread-screen.tsx index fc2fff5..52c399d 100644 --- a/apps/mobile/src/features/threads/hermes-thread-screen.tsx +++ b/apps/mobile/src/features/threads/hermes-thread-screen.tsx @@ -47,7 +47,12 @@ import { isNamedProfile } from '@/lib/profiles'; import { buildRuntimeModelOptions, normalizeLiveUsage } from '@/lib/session-runtime'; import { useRunStore } from '@/state/run-store'; import { useComposerStore } from '@/state/composer-store'; -import { EMPTY_PROMPT_QUEUE, type QueuedPrompt } from '@/state/composer-store-model'; +import { + EMPTY_COMPOSER_ATTACHMENTS, + EMPTY_PROMPT_HISTORY, + EMPTY_PROMPT_QUEUE, + type QueuedPrompt, +} from '@/state/composer-store-model'; import type { ChatModelOverride, ChatThread } from '@/state/chat-thread-model'; type FeedItem = HermesMessage & { id: string }; @@ -120,10 +125,14 @@ export function HermesThreadScreen({ const clearActiveRun = useRunStore((state) => state.clearActiveRun); const composerHydrated = useComposerStore((state) => state.hydrated); const draft = useComposerStore((state) => state.drafts[composerKey] ?? ''); - const attachments = useComposerStore((state) => state.attachments[composerKey] ?? []); + const attachments = useComposerStore( + (state) => state.attachments[composerKey] ?? EMPTY_COMPOSER_ATTACHMENTS, + ); const queue = useComposerStore((state) => state.queues[composerKey] ?? EMPTY_PROMPT_QUEUE); const queuePaused = useComposerStore((state) => Boolean(state.paused[composerKey])); - const history = useComposerStore((state) => state.promptHistory[composerKey] ?? []); + const history = useComposerStore( + (state) => state.promptHistory[composerKey] ?? EMPTY_PROMPT_HISTORY, + ); const revisions = useComposerStore((state) => state.draftRevisions[composerKey]); const composerStorageError = useComposerStore((state) => state.storageError); const setDraft = useComposerStore((state) => state.setDraft); diff --git a/apps/mobile/src/lib/brio.test.mjs b/apps/mobile/src/lib/brio.test.mjs index acc8bd4..f80bdd6 100644 --- a/apps/mobile/src/lib/brio.test.mjs +++ b/apps/mobile/src/lib/brio.test.mjs @@ -2,29 +2,105 @@ import assert from 'node:assert/strict'; import { Buffer } from 'node:buffer'; import test from 'node:test'; +import { + explainConnectionError, + friendlyPayloadError, + validateManualConnection, +} from '../features/connection/connection-experience.ts'; +import { + removeStoredConnection, + removeStoredConnectionsWhere, + upsertStoredConnection, + validStoredConnections, +} from '../state/connection-store-model.ts'; import { aggregateRootAgentUsage, + brioFetch, connectionFromPairingPayload, decodePairingPayload, extractPairingPayload, filterAgentsForControlSession, finalizeConnection, getHealth, + normalizeMessageList, + normalizeCapabilities, + normalizeFileList, + normalizeHealth, + normalizeSessionList, + normalizeSkills, + normalizeToolsets, normalizeConnectionURL, parseGoalStatus, parseHeartbeatStatus, } from './brio.ts'; -import { - removeStoredConnection, - removeStoredConnectionsWhere, - upsertStoredConnection, - validStoredConnections, -} from '../state/connection-store-model.ts'; -import { - explainConnectionError, - friendlyPayloadError, - validateManualConnection, -} from '../features/connection/connection-experience.ts'; + +test('normalizes current Hermes list envelopes without breaking legacy responses', () => { + const sessions = [{ id: 's1' }]; + const messages = [{ role: 'assistant', content: 'ok' }]; + assert.deepEqual(normalizeSessionList({ data: sessions }).sessions, sessions); + assert.deepEqual(normalizeSessionList({ sessions }).sessions, sessions); + assert.deepEqual(normalizeMessageList({ data: messages }).messages, messages); + assert.deepEqual(normalizeMessageList({ messages }).messages, messages); +}); + +test('normalizes native Hermes health and capabilities for connection screens', () => { + assert.deepEqual( + normalizeHealth({ status: 'ok', platform: 'hermes-agent', version: '0.20.5' }), + { + status: 'ok', + platform: 'hermes-agent', + version: '0.20.5', + ok: true, + agent_ok: true, + agent_kind: 'hermes', + agent_name: 'Hermes Agent', + hermes_ok: true, + }, + ); + assert.deepEqual( + normalizeCapabilities({ features: { responses_api: true, audio_api: false } }).companion, + { responses_api: true, audio_api: false }, + ); +}); + +test('normalizes current Hermes dashboard resources for the Mobile screens', () => { + const files = normalizeFileList({ + path: '/workspace', + entries: [{ name: 'src', path: '/workspace/src', is_directory: true, size: null }], + root: '/workspace', + }); + assert.equal(files.entries[0].dir, true); + assert.equal(files.entries[0].size, 0); + assert.deepEqual(files.roots, ['/workspace']); + + const skills = normalizeSkills([ + { name: 'browser', category: 'web', provenance: 'bundled', enabled: true }, + ]); + assert.equal(skills.skills[0].path, 'bundled'); + + const toolsets = normalizeToolsets([ + { name: 'browser', platform: 'cli', enabled: true }, + { name: 'vision', platform: 'cli', enabled: false }, + ]); + assert.deepEqual(toolsets.toolsets, { cli: ['browser'] }); +}); + +test('surfaces structured API errors as useful messages', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response(JSON.stringify({ error: { code: 'not_found', message: 'Session does not exist' } }), { + headers: { 'Content-Type': 'application/json' }, + status: 404, + }); + try { + await assert.rejects( + brioFetch({ url: 'http://127.0.0.1:8787', token: 'secret', transport: 'direct' }, '/missing'), + /Session does not exist/, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); const directPayload = { url: 'http://192.168.1.25:8787', diff --git a/apps/mobile/src/lib/brio.ts b/apps/mobile/src/lib/brio.ts index 91f43e8..78d84b6 100644 --- a/apps/mobile/src/lib/brio.ts +++ b/apps/mobile/src/lib/brio.ts @@ -34,7 +34,10 @@ export type AgentConnection = { }; export type HealthResponse = { - ok: boolean; + ok?: boolean; + status?: string; + platform?: string; + version?: string; agent_ok?: boolean; agent_kind?: string; agent_name?: string; @@ -49,6 +52,7 @@ export type HealthResponse = { export type CapabilitiesResponse = { companion?: Record; + features?: Record; hermes?: unknown; }; @@ -136,6 +140,92 @@ export type HermesSearchResult = { snippet: string; }; +type HermesSessionListEnvelope = { + sessions?: HermesSession[]; + data?: HermesSession[]; + error?: string; +}; + +type HermesMessageListEnvelope = { + messages?: HermesMessage[]; + data?: HermesMessage[]; + error?: string; +}; + +export function normalizeSessionList(response: HermesSessionListEnvelope) { + return { ...response, sessions: response.sessions ?? response.data ?? [] }; +} + +export function normalizeMessageList(response: HermesMessageListEnvelope) { + return { ...response, messages: response.messages ?? response.data ?? [] }; +} + +type HermesControlFileEntry = Omit, 'size'> & { + is_directory?: boolean; + size?: number | null; +}; +type HermesControlFileList = { + path: string; + entries?: HermesControlFileEntry[]; + roots?: string[]; + root?: string | null; + locked_root?: string | null; + error?: string; +}; + +export function normalizeFileList(response: HermesControlFileList) { + const roots = response.roots ?? [response.root, response.locked_root].filter( + (value): value is string => Boolean(value), + ); + return { + ...response, + entries: (response.entries ?? []).map((entry) => ({ + ...entry, + name: entry.name ?? '', + path: entry.path ?? '', + dir: entry.dir ?? entry.is_directory ?? false, + size: entry.size ?? 0, + })), + roots: Array.from(new Set(roots)), + }; +} + +type HermesControlSkill = Partial & { provenance?: string }; + +export function normalizeSkills(response: HermesControlSkill[] | { skills?: HermesControlSkill[] }) { + const skills = Array.isArray(response) ? response : (response.skills ?? []); + return { + skills: skills.map((skill) => ({ + name: skill.name ?? 'Unnamed skill', + category: skill.category ?? '', + path: skill.path ?? skill.provenance ?? skill.name ?? 'unknown', + description: skill.description ?? '', + enabled: skill.enabled ?? false, + })), + }; +} + +type HermesControlToolset = { + name?: string; + platform?: string; + enabled?: boolean; +}; + +export function normalizeToolsets( + response: HermesControlToolset[] | { toolsets?: Record; error?: string }, +) { + if (!Array.isArray(response)) { + return { toolsets: response.toolsets ?? {}, error: response.error }; + } + const toolsets: Record = {}; + for (const item of response) { + if (!item.enabled || !item.name) continue; + const platform = item.platform || 'cli'; + (toolsets[platform] ??= []).push(item.name); + } + return { toolsets }; +} + export type HermesRunStatus = { object: 'hermes.run'; run_id: string; @@ -433,8 +523,7 @@ export async function brioFetch( const text = await response.text(); const body = text ? JSON.parse(text) : null; if (!response.ok) { - const message = body?.error ?? body?.message ?? `Request failed: ${response.status}`; - throw new Error(message); + throw new Error(apiErrorMessage(body?.error ?? body?.message, `Request failed: ${response.status}`)); } return body as T; } @@ -447,17 +536,34 @@ function requestTimeoutForPath(path: string) { : 15_000; } -export function getHealth( +export async function getHealth( connection: Pick & Partial, signal?: AbortSignal, ) { - return brioFetch(connection, '/health', { signal }); + return normalizeHealth(await brioFetch(connection, '/health', { signal })); } -export function getCapabilities( +export async function getCapabilities( connection: Pick & Partial, ) { - return brioFetch(connection, '/capabilities'); + return normalizeCapabilities(await brioFetch(connection, '/capabilities')); +} + +export function normalizeHealth(response: HealthResponse): HealthResponse { + if (response.status !== 'ok' || response.platform !== 'hermes-agent') return response; + return { + ...response, + ok: true, + agent_ok: true, + agent_kind: response.agent_kind ?? 'hermes', + agent_name: response.agent_name ?? 'Hermes Agent', + hermes_ok: true, + }; +} + +export function normalizeCapabilities(response: CapabilitiesResponse): CapabilitiesResponse { + if (response.companion || !response.features) return response; + return { ...response, companion: response.features }; } export function getComposerCapabilities(connection: AgentConnection, profile?: string) { @@ -568,25 +674,27 @@ export function interruptComposerSession(connection: AgentConnection, sessionId: }); } -export function listSessions(connection: AgentConnection, limit = 100, profile?: string) { - return brioFetch<{ sessions: HermesSession[]; error?: string }>( +export async function listSessions(connection: AgentConnection, limit = 100, profile?: string) { + const response = await brioFetch( connection, - `${scopedPath('/sessions', profile)}?limit=${limit}`, + `${scopedPath('/api/sessions', profile)}?limit=${limit}`, ); + return normalizeSessionList(response); } export function searchSessions(connection: AgentConnection, query: string, profile?: string) { return brioFetch<{ results: HermesSearchResult[]; error?: string }>( connection, - `${scopedPath('/sessions/search', profile)}?q=${encodeURIComponent(query)}&limit=100`, + `${scopedPath('/api/sessions/search', profile)}?q=${encodeURIComponent(query)}&limit=100`, ); } -export function getSessionMessages(connection: AgentConnection, sessionId: string, profile?: string) { - return brioFetch<{ messages: HermesMessage[]; error?: string }>( +export async function getSessionMessages(connection: AgentConnection, sessionId: string, profile?: string) { + const response = await brioFetch( connection, - scopedPath(`/sessions/${encodeURIComponent(sessionId)}/messages`, profile), + scopedPath(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, profile), ); + return normalizeMessageList(response); } export function startRun( @@ -748,13 +856,12 @@ export function stopRun(connection: AgentConnection, runId: string, profile?: st ); } -export function listFiles(connection: AgentConnection, path?: string) { - return brioFetch<{ - path: string; - entries: HermesFileEntry[]; - roots?: string[]; - error?: string; - }>(connection, `/files${path ? `?path=${encodeURIComponent(path)}` : ''}`); +export async function listFiles(connection: AgentConnection, path?: string) { + const response = await brioFetch( + connection, + `/files${path ? `?path=${encodeURIComponent(path)}` : ''}`, + ); + return normalizeFileList(response); } export function readFile(connection: AgentConnection, path: string) { @@ -796,15 +903,22 @@ export function updateRawConfig(connection: AgentConnection, yaml: string) { }); } -export function listSkills(connection: AgentConnection) { - return brioFetch<{ skills: HermesSkill[] }>(connection, '/skills'); +export async function listSkills(connection: AgentConnection) { + const response = await brioFetch( + connection, + '/skills', + ); + return normalizeSkills(response); } -export function getToolsets(connection: AgentConnection) { - return brioFetch<{ toolsets: Record; error?: string }>( +export async function getToolsets(connection: AgentConnection) { + const response = await brioFetch< + HermesControlToolset[] | { toolsets?: Record; error?: string } + >( connection, '/tools/toolsets', ); + return normalizeToolsets(response); } export function updateToolset( @@ -820,11 +934,18 @@ export function updateToolset( ); } -export function getGatewayStatus(connection: AgentConnection) { - return brioFetch<{ running: boolean; status?: unknown; raw?: string }>( +export async function getGatewayStatus(connection: AgentConnection) { + const response = await brioFetch<{ + running?: boolean; + gateway_running?: boolean; + status?: unknown; + raw?: string; + [key: string]: unknown; + }>( connection, '/gateway/status', ); + return { ...response, running: response.running ?? response.gateway_running ?? false }; } export function restartGateway(connection: AgentConnection) { @@ -2037,10 +2158,12 @@ class RelaySocketClient { } if ((frame.status ?? 500) >= 400) { - const message = + const message = apiErrorMessage( typeof frame.body === 'object' && frame.body && 'error' in frame.body - ? String((frame.body as { error?: unknown }).error) - : `Request failed: ${frame.status}`; + ? (frame.body as { error?: unknown }).error + : undefined, + `Request failed: ${frame.status}`, + ); pending.reject(new Error(message)); return; } @@ -2096,6 +2219,17 @@ class RelaySocketClient { } } +function apiErrorMessage(value: unknown, fallback: string): string { + if (typeof value === 'string' && value.trim()) return value; + if (value && typeof value === 'object') { + const nested = value as { message?: unknown; detail?: unknown; code?: unknown }; + for (const candidate of [nested.message, nested.detail, nested.code]) { + if (typeof candidate === 'string' && candidate.trim()) return candidate; + } + } + return fallback; +} + function relayChannelID() { return `channel_${Date.now()}_${Math.random().toString(16).slice(2)}`; } diff --git a/apps/mobile/src/state/composer-store-model.ts b/apps/mobile/src/state/composer-store-model.ts index d3bbf68..bc20569 100644 --- a/apps/mobile/src/state/composer-store-model.ts +++ b/apps/mobile/src/state/composer-store-model.ts @@ -40,6 +40,8 @@ export const EMPTY_COMPOSER_STATE: StoredComposerState = { }; export const EMPTY_PROMPT_QUEUE: QueuedPrompt[] = []; +export const EMPTY_COMPOSER_ATTACHMENTS: ComposerAttachment[] = []; +export const EMPTY_PROMPT_HISTORY: string[] = []; export type EnqueuedComposerDraft = { prompt: QueuedPrompt; diff --git a/deploy/oracle/.env.example b/deploy/oracle/.env.example new file mode 100644 index 0000000..ec05ac0 --- /dev/null +++ b/deploy/oracle/.env.example @@ -0,0 +1,26 @@ +# Use a stable Brio-owned domain in production. +BRIO_DOMAIN=relay.example.com + +# Generate with: openssl rand -hex 32 +POSTGRES_PASSWORD=replace-with-a-random-password + +# React Native Android sends the relay's own HTTPS origin on WebSocket +# upgrades. Add separate browser app origins as a comma-separated list. +BRIO_RELAY_ALLOWED_ORIGINS=https://relay.example.com + +# Caddy connects from the private Docker bridge and supplies X-Forwarded-Proto. +BRIO_RELAY_TRUSTED_PROXY_CIDRS=172.16.0.0/12 + +# Migration-only. Enable while an older installed connector or app still puts +# credentials in WebSocket query strings, then disable after both are upgraded. +BRIO_ALLOW_LEGACY_QUERY_TOKENS=false + +# Production registration fails closed unless Clerk or the operations-only +# registration key is configured. Never enable insecure development mode here. +BRIO_INSECURE_DEV_MODE=false +BRIO_DEVICE_REGISTRATION_KEY= +BRIO_CLERK_SECRET_KEY= +BRIO_CLERK_JWT_KEY= +BRIO_CLERK_ISSUER= +BRIO_CLERK_JWT_AUDIENCE=brio-relay +BRIO_CLERK_AUTHORIZED_PARTIES= diff --git a/deploy/oracle/Caddyfile b/deploy/oracle/Caddyfile new file mode 100644 index 0000000..d28c4d1 --- /dev/null +++ b/deploy/oracle/Caddyfile @@ -0,0 +1,4 @@ +{$BRIO_DOMAIN} { + encode zstd gzip + reverse_proxy relay:8080 +} diff --git a/deploy/oracle/README.md b/deploy/oracle/README.md new file mode 100644 index 0000000..2127197 --- /dev/null +++ b/deploy/oracle/README.md @@ -0,0 +1,26 @@ +# Oracle deployment + +This Compose stack runs exactly one Brio relay replica, PostgreSQL for durable +user/device/agent state, and Caddy for automatic HTTPS and WebSocket proxying. + +Copy `.env.example` to `.env`, set a strong PostgreSQL password and a domain +whose A record points to the VM, then run: + +```sh +docker compose up -d --build +``` + +Configure either Clerk or `BRIO_DEVICE_REGISTRATION_KEY` before issuing new +device credentials. Existing device and connector tokens remain valid when +registration is closed. Keep `BRIO_INSECURE_DEV_MODE=false` on the public +relay. During a client migration only, set +`BRIO_ALLOW_LEGACY_QUERY_TOKENS=true`; return it to `false` as soon as the +mobile app and connector are both current. + +Set `BRIO_RELAY_ALLOWED_ORIGINS` to the relay's public HTTPS origin (for +example, `https://relay.example.com`). React Native Android includes that +origin on WebSocket upgrades; add separate browser app origins as a +comma-separated list when needed. + +Do not scale the relay above one replica until its live peer hub is moved out +of process memory or sticky peer routing is implemented. diff --git a/deploy/oracle/docker-compose.yml b/deploy/oracle/docker-compose.yml new file mode 100644 index 0000000..d4eb57c --- /dev/null +++ b/deploy/oracle/docker-compose.yml @@ -0,0 +1,57 @@ +services: + postgres: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_DB: brio + POSTGRES_USER: brio + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U brio -d brio"] + interval: 5s + timeout: 5s + retries: 20 + + relay: + build: + context: ../../apps/relay + restart: unless-stopped + environment: + BRIO_RELAY_ADDR: :8080 + BRIO_DATABASE_URL: postgres://brio:${POSTGRES_PASSWORD}@postgres:5432/brio?sslmode=disable + BRIO_RELAY_ALLOWED_ORIGINS: ${BRIO_RELAY_ALLOWED_ORIGINS:-} + BRIO_RELAY_TRUSTED_PROXY_CIDRS: ${BRIO_RELAY_TRUSTED_PROXY_CIDRS:-172.16.0.0/12} + BRIO_ALLOW_LEGACY_QUERY_TOKENS: ${BRIO_ALLOW_LEGACY_QUERY_TOKENS:-false} + BRIO_INSECURE_DEV_MODE: ${BRIO_INSECURE_DEV_MODE:-false} + BRIO_DEVICE_REGISTRATION_KEY: ${BRIO_DEVICE_REGISTRATION_KEY:-} + BRIO_CLERK_SECRET_KEY: ${BRIO_CLERK_SECRET_KEY:-} + BRIO_CLERK_JWT_KEY: ${BRIO_CLERK_JWT_KEY:-} + BRIO_CLERK_ISSUER: ${BRIO_CLERK_ISSUER:-} + BRIO_CLERK_JWT_AUDIENCE: ${BRIO_CLERK_JWT_AUDIENCE:-brio-relay} + BRIO_CLERK_AUTHORIZED_PARTIES: ${BRIO_CLERK_AUTHORIZED_PARTIES:-} + depends_on: + postgres: + condition: service_healthy + + caddy: + image: caddy:2-alpine + restart: unless-stopped + environment: + BRIO_DOMAIN: ${BRIO_DOMAIN:?set BRIO_DOMAIN in .env} + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + depends_on: + - relay + +volumes: + postgres-data: + caddy-data: + caddy-config: diff --git a/go.work.sum b/go.work.sum index 1c731d1..f2b618d 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,10 +1,19 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= +github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= +golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc= gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= From 6fe0115e071a287e5dd3169a07fe6f6409a89fbd Mon Sep 17 00:00:00 2001 From: Kevin Bravo Date: Wed, 26 Aug 2026 14:27:44 -0400 Subject: [PATCH 2/2] feat(mobile): refresh Hermes composer and model picker - Add compact and expanded composer states - Stage thread model changes before saving - Support model overrides from thread routes --- apps/mobile/scripts/session-runtime.test.mjs | 1 + apps/mobile/src/app/thread/[id].tsx | 20 +- apps/mobile/src/components/app-tabs.web.tsx | 13 +- .../src/components/composer-controls.tsx | 161 +++- .../src/components/session-model-controls.tsx | 533 ++++++++----- apps/mobile/src/components/t3-ui.tsx | 3 +- apps/mobile/src/components/ui/collapsible.tsx | 12 +- .../hermes-command-center-screen.tsx | 8 +- .../connection/environments-screen.tsx | 25 +- .../connection/relay-environments-screen.tsx | 15 - .../connection/relay-sign-in-screen.tsx | 17 +- .../features/files/hermes-files-screen.tsx | 20 +- .../src/features/home/hermes-home-screen.tsx | 711 +++++++++++++----- .../settings/hermes-settings-screen.tsx | 2 +- .../features/threads/hermes-thread-screen.tsx | 102 ++- apps/mobile/src/lib/session-runtime.ts | 3 +- .../src/state/composer-store-model.test.mjs | 30 + apps/mobile/src/state/composer-store-model.ts | 42 +- apps/mobile/src/state/composer-store.ts | 10 +- 19 files changed, 1178 insertions(+), 550 deletions(-) diff --git a/apps/mobile/scripts/session-runtime.test.mjs b/apps/mobile/scripts/session-runtime.test.mjs index 83acdcc..92ed3d2 100644 --- a/apps/mobile/scripts/session-runtime.test.mjs +++ b/apps/mobile/scripts/session-runtime.test.mjs @@ -394,6 +394,7 @@ test('modelPricingFor resolves per-model maps and single pricing objects', () => test('modelCostLabel uses exact backend strings and never synthesizes zero', () => { assert.equal(modelCostLabel({ input: '3', output: '15', cache: null, free: false }), '$3 in · $15 out per Mtok'); + assert.equal(modelCostLabel({ input: '$3', output: '$15', free: false }), '$3 in · $15 out per Mtok'); assert.equal(modelCostLabel({ input: '0', output: '0', free: false }), '$0 in · $0 out per Mtok'); assert.equal(modelCostLabel({ free: true }), 'Free'); // Missing price means unknown, never zero. diff --git a/apps/mobile/src/app/thread/[id].tsx b/apps/mobile/src/app/thread/[id].tsx index b46b984..422dce6 100644 --- a/apps/mobile/src/app/thread/[id].tsx +++ b/apps/mobile/src/app/thread/[id].tsx @@ -2,16 +2,34 @@ import { Redirect, useLocalSearchParams } from 'expo-router'; import { HermesThreadScreen } from '@/features/threads/hermes-thread-screen'; import { profileName } from '@/lib/profiles'; +import type { ChatModelOverride } from '@/state/chat-thread-model'; import { useConnectionStore } from '@/state/connection-store'; export default function ThreadRoute() { - const { id, profile } = useLocalSearchParams<{ id: string; profile?: string }>(); + const { effort, fast, id, model, profile, provider } = useLocalSearchParams<{ + effort?: string; + fast?: string; + id: string; + model?: string; + profile?: string; + provider?: string; + }>(); const connection = useConnectionStore((state) => state.connection); + const initialModelOverride: ChatModelOverride | undefined = + provider?.trim() && model?.trim() + ? { + provider: provider.trim(), + model: model.trim(), + ...(effort?.trim() ? { reasoningEffort: effort.trim() } : {}), + ...(fast === 'true' || fast === 'false' ? { fast: fast === 'true' } : {}), + } + : undefined; if (!connection) return ; return ( diff --git a/apps/mobile/src/components/app-tabs.web.tsx b/apps/mobile/src/components/app-tabs.web.tsx index d5e24df..9d4823a 100644 --- a/apps/mobile/src/components/app-tabs.web.tsx +++ b/apps/mobile/src/components/app-tabs.web.tsx @@ -6,7 +6,6 @@ import { type TabListProps, type TabTriggerSlotProps, } from 'expo-router/ui'; -import { SymbolView, type SymbolViewProps } from 'expo-symbols'; import { Pressable, StyleSheet, View } from 'react-native'; import { SectionLabel, StatusBadge, ThemeSwitcher } from './dashboard'; @@ -31,10 +30,10 @@ export default function AppTabs() { - + - + @@ -56,10 +55,10 @@ function SidebarNav({ children, compact, ...props }: TabListProps & { compact?: ]}> - Brio + Hermes - Hermes Agent + Connected agent @@ -94,11 +93,10 @@ function SidebarNav({ children, compact, ...props }: TabListProps & { compact?: } function SidebarButton({ - icon, isFocused, label, ...props -}: TabTriggerSlotProps & { icon: SymbolViewProps['name']; label: string }) { +}: TabTriggerSlotProps & { label: string }) { const colors = useTheme(); return ( @@ -112,7 +110,6 @@ function SidebarButton({ opacity: pressed ? 0.75 : 1, }, ]}> - {label} diff --git a/apps/mobile/src/components/composer-controls.tsx b/apps/mobile/src/components/composer-controls.tsx index 0d18afe..1b4d8aa 100644 --- a/apps/mobile/src/components/composer-controls.tsx +++ b/apps/mobile/src/components/composer-controls.tsx @@ -38,6 +38,8 @@ export function ComposerControls({ draft, history, hydrated, + forceExpanded = false, + modelControl, onAddAttachment, onDraftChange, onRedo, @@ -45,6 +47,7 @@ export function ComposerControls({ onSend, onUndo, profile, + sendDisabled = false, sessionId, }: { active: boolean; @@ -55,6 +58,8 @@ export function ComposerControls({ draft: string; history: string[]; hydrated: boolean; + forceExpanded?: boolean; + modelControl?: ReactNode; onAddAttachment: (attachment: ComposerAttachment) => Promise; onDraftChange: (text: string) => void; onRedo: () => void; @@ -62,6 +67,7 @@ export function ComposerControls({ onSend: (mode: PromptDeliveryMode) => void; onUndo: () => void; profile: string; + sendDisabled?: boolean; sessionId: string; }) { const colors = useTheme(); @@ -70,6 +76,7 @@ export function ComposerControls({ const [pickerOpen, setPickerOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [commandsOpen, setCommandsOpen] = useState(false); + const [inputFocused, setInputFocused] = useState(false); const [uploads, setUploads] = useState([]); const [error, setError] = useState(''); const token = completionToken(draft); @@ -94,7 +101,21 @@ export function ComposerControls({ enabled: commandsOpen, staleTime: 60_000, }); - const canSend = hydrated && (Boolean(draft.trim()) || attachments.length > 0) && uploads.length === 0; + const canSend = + hydrated && + !sendDisabled && + (Boolean(draft.trim()) || attachments.length > 0) && + uploads.length === 0; + const expanded = + forceExpanded || + inputFocused || + pickerOpen || + historyOpen || + commandsOpen || + attachments.length > 0 || + uploads.length > 0 || + Boolean(error) || + Boolean(incomingShare.error); const uploadSources = useCallback(async (sources: AttachmentSource[]) => { setPickerOpen(false); @@ -278,34 +299,74 @@ export function ComposerControls({ ) : null} - - setPickerOpen(true)} /> - setCommandsOpen(true)} /> - setHistoryOpen(true)} /> - - - {active ? onSend('redirect')} tone="warning" /> : null} - - - - - onSend('queue')} - style={({ pressed }) => [styles.send, { backgroundColor: canSend ? colors.accent : colors.backgroundSelected, opacity: pressed ? 0.72 : 1 }]}> - - + + + setInputFocused(false)} + onChangeText={onDraftChange} + onFocus={() => setInputFocused(true)} + placeholder={active ? 'Ask a follow-up…' : 'Ask Hermes anything…'} + placeholderTextColor={colors.textTertiary} + scrollEnabled={expanded} + style={[ + styles.input, + expanded ? styles.inputExpanded : styles.inputCollapsed, + { color: colors.text }, + ]} + textAlignVertical={expanded ? 'top' : 'center'} + value={draft} + /> + {!expanded ? ( + onSend('queue')} + style={({ pressed }) => [ + styles.send, + { + backgroundColor: canSend ? colors.accent : colors.backgroundSelected, + opacity: pressed ? 0.72 : 1, + }, + ]}> + + ↑ + + + ) : null} + + {expanded ? ( + + + setPickerOpen(true)} /> + {modelControl} + setCommandsOpen(true)} /> + {history.length > 0 ? setHistoryOpen(true)} /> : null} + {canUndo ? : null} + {canRedo ? : null} + {active ? onSend('redirect')} tone="warning" /> : null} + + onSend('queue')} + style={({ pressed }) => [styles.send, { backgroundColor: canSend ? colors.accent : colors.backgroundSelected, opacity: pressed ? 0.72 : 1 }]}> + + + + ) : null} setPickerOpen(false)} onDocuments={() => void chooseDocuments()} onImages={() => void chooseImages()} onPhoto={() => void takePhoto()} visible={pickerOpen} /> @@ -328,11 +389,18 @@ function AttachmentChip({ detail, name, onRemove, tone = 'normal' }: { detail: s ); } -function ToolbarAction({ disabled, label, onPress, tone = 'normal' }: { disabled?: boolean; label: string; onPress: () => void; tone?: 'normal' | 'warning' }) { +function ToolbarAction({ accessibilityLabel, disabled, label, onPress, tone = 'normal' }: { accessibilityLabel?: string; disabled?: boolean; label: string; onPress: () => void; tone?: 'normal' | 'warning' }) { const colors = useTheme(); return ( - - {label} + [ + styles.toolbarAction, + { backgroundColor: colors.backgroundSelected, opacity: disabled ? 0.35 : pressed ? 0.6 : 1 }, + ]}> + {label} ); } @@ -414,10 +482,31 @@ function formatBytes(value: number) { } const styles = StyleSheet.create({ - toolbar: { alignItems: 'center', flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.three, paddingBottom: Spacing.two }, - composer: { alignItems: 'flex-end', borderRadius: 18, borderWidth: StyleSheet.hairlineWidth, flexDirection: 'row', gap: Spacing.two, minHeight: 58, padding: Spacing.two, paddingLeft: Spacing.three }, - input: { flex: 1, fontSize: 16, lineHeight: 23, maxHeight: 150, minHeight: 40, outlineStyle: 'none', paddingBottom: 8, paddingTop: 8 } as never, - send: { alignItems: 'center', borderRadius: 13, height: 42, justifyContent: 'center', width: 42 }, + toolbar: { alignItems: 'center', flexDirection: 'row', gap: Spacing.two, paddingRight: Spacing.two }, + toolbarAction: { borderRadius: 999, minHeight: 34, justifyContent: 'center', paddingHorizontal: 11 }, + composer: { borderWidth: StyleSheet.hairlineWidth, overflow: 'hidden' }, + composerCollapsed: { + borderRadius: 999, + minHeight: 54, + paddingBottom: 5, + paddingLeft: 18, + paddingRight: 5, + paddingTop: 5, + }, + composerExpanded: { + borderRadius: 26, + minHeight: 140, + paddingBottom: 6, + paddingHorizontal: 14, + paddingTop: 14, + }, + collapsedInputRow: { alignItems: 'center', flexDirection: 'row' }, + expandedInputRow: { minHeight: 72 }, + input: { flex: 1, fontSize: 16, lineHeight: 23, outlineStyle: 'none' } as never, + inputCollapsed: { height: 36, paddingBottom: 4, paddingTop: 4 }, + inputExpanded: { maxHeight: 150, minHeight: 72, paddingHorizontal: 4, paddingVertical: 4 }, + composerFooter: { alignItems: 'center', flexDirection: 'row', gap: Spacing.two }, + send: { alignItems: 'center', borderRadius: 22, height: 44, justifyContent: 'center', width: 44 }, attachmentList: { gap: Spacing.two, paddingBottom: Spacing.two }, attachmentChip: { alignItems: 'center', borderRadius: 10, borderWidth: StyleSheet.hairlineWidth, flexDirection: 'row', gap: Spacing.two, paddingHorizontal: Spacing.two, paddingVertical: Spacing.one }, completions: { borderRadius: 12, borderWidth: StyleSheet.hairlineWidth, marginBottom: Spacing.two, maxHeight: 220 }, diff --git a/apps/mobile/src/components/session-model-controls.tsx b/apps/mobile/src/components/session-model-controls.tsx index 6a9e4c4..1a7ece5 100644 --- a/apps/mobile/src/components/session-model-controls.tsx +++ b/apps/mobile/src/components/session-model-controls.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Modal, @@ -16,11 +16,8 @@ import { useTheme } from '@/hooks/use-theme'; import { getModelPreset, setModelPreset } from '@/lib/model-presets'; import { aggregateSessionAnalytics, - modelCapabilityBadges, - modelCostLabel, modelIncompatibilities, modelIsUnavailable, - modelPricingFor, reasoningEffortChoices, selectedCapabilities, type ReasoningEffortChoice, @@ -36,6 +33,11 @@ export type SessionModelControlsProps = { /** Loaded sessions feed the analytics panel; insertion/backend order is kept. */ sessions?: HermesSession[]; onOverrideChange: (override: ChatModelOverride | undefined) => void; + onOpenChange?: (open: boolean) => void; + /** T3-style composer control: only the effective model name in a small pill. */ + variant?: 'bar' | 'inline'; + /** New-thread pickers do not have session usage to display yet. */ + showUsage?: boolean; }; type PanelKind = 'closed' | 'models' | 'usage'; @@ -53,9 +55,19 @@ export function SessionModelControls({ optionsError, sessions, onOverrideChange, + onOpenChange, + variant = 'bar', + showUsage = true, }: SessionModelControlsProps) { const colors = useTheme(); const [panel, setPanel] = useState('closed'); + const [draftOverride, setDraftOverride] = useState( + thread.modelOverride, + ); + const [draftDirty, setDraftDirty] = useState(false); + const [pickerSession, setPickerSession] = useState(0); + const draftOverrideRef = useRef(thread.modelOverride); + const draftDirtyRef = useRef(false); const override = thread.modelOverride; const loadingSummary = optionsLoading && !options; @@ -73,6 +85,33 @@ export function SessionModelControls({ } else { summaryLabel = 'Profile default · unknown'; } + const inlineLabel = + override?.model ?? + options?.model ?? + (loadingSummary ? 'Loading models…' : errorSummary ? 'Models unavailable' : 'Choose model'); + const openModels = () => { + draftOverrideRef.current = override; + draftDirtyRef.current = false; + setDraftOverride(override); + setDraftDirty(false); + setPickerSession((current) => current + 1); + setPanel('models'); + onOpenChange?.(true); + }; + const dismissPanel = () => { + setPanel('closed'); + onOpenChange?.(false); + }; + const commitAndClose = () => { + if (draftDirtyRef.current) onOverrideChange(draftOverrideRef.current); + dismissPanel(); + }; + const stageOverride = (nextOverride: ChatModelOverride | undefined) => { + draftOverrideRef.current = nextOverride; + draftDirtyRef.current = true; + setDraftOverride(nextOverride); + setDraftDirty(true); + }; return ( <> @@ -81,63 +120,83 @@ export function SessionModelControls({ override ? 'Session model picker: session override active' : 'Session model picker: profile default active' } accessibilityRole="button" - onPress={() => setPanel('models')} + onPress={openModels} style={({ pressed }) => [ - styles.bar, + variant === 'inline' ? styles.inline : styles.bar, { - backgroundColor: colors.panel, + backgroundColor: + variant === 'inline' ? colors.backgroundSelected : colors.panel, borderColor: override ? colors.accent : colors.border, opacity: pressed ? 0.72 : 1, }, ]}> - - - {summaryLabel} + {variant === 'bar' ? ( + + ) : null} + + {variant === 'inline' ? inlineLabel : summaryLabel} - + setPanel('closed')} + onRequestClose={dismissPanel} transparent={false} visible={panel !== 'closed'}> - Model & session setPanel('closed')}> - Done + hitSlop={10} + onPress={dismissPanel} + style={styles.headerAction}> + + + Thread settings + + - - setPanel('models')} - /> - setPanel('usage')} - /> - - {panel === 'usage' ? ( + {showUsage ? ( + + setPanel('models')} + /> + setPanel('usage')} + /> + + ) : null} + {showUsage && panel === 'usage' ? ( ) : ( )} @@ -191,6 +250,7 @@ function ModelPickerPanel({ }) { const colors = useTheme(); const [search, setSearch] = useState(''); + const [providerExpansion, setProviderExpansion] = useState>({}); // A pending change awaits explicit confirmation when switching models — or // clearing the override back to the profile default — on a thread that // already has traffic (prompt cache may be invalidated). @@ -201,6 +261,7 @@ function ModelPickerPanel({ >(null); const [draftEffort, setDraftEffort] = useState(null); const [draftFast, setDraftFast] = useState(null); + const [reasoningExpanded, setReasoningExpanded] = useState(false); const safeOptions = options ?? EMPTY_OPTIONS; const effectiveProvider = override?.provider ?? safeOptions.provider ?? null; @@ -210,27 +271,45 @@ function ModelPickerPanel({ [safeOptions, effectiveProvider, effectiveModel], ); - // Search filters dynamically but never reorders: providers stay in backend - // order and models stay in each provider's backend order. + const appliedProvider = thread.modelOverride?.provider ?? safeOptions.provider ?? null; + + // Search filters dynamically but never reorders. A narrowed catalog expands + // matching providers; otherwise the applied and primary providers start open. const groups = useMemo(() => { const query = search.trim().toLowerCase(); - if (!query) { - return safeOptions.providers.map((provider) => ({ provider, models: provider.models ?? [] })); - } return safeOptions.providers .map((provider) => ({ provider, - matchesProvider: - provider.name.toLowerCase().includes(query) || provider.slug.toLowerCase().includes(query), - models: (provider.models ?? []).filter((model) => model.toLowerCase().includes(query)), - })) - .map(({ provider, matchesProvider, models }) => ({ - provider, - models: matchesProvider ? (provider.models ?? []) : models, + models: + !query || + provider.name.toLowerCase().includes(query) || + provider.slug.toLowerCase().includes(query) + ? (provider.models ?? []) + : (provider.models ?? []).filter((model) => model.toLowerCase().includes(query)), })) .filter((group) => group.models.length > 0); }, [safeOptions.providers, search]); + function providerIsExpanded(provider: ModelOptionProvider) { + if (search.trim()) return true; + const explicit = providerExpansion[provider.slug]; + if (explicit !== undefined) return explicit; + const providerIdentity = [provider.slug, provider.name, provider.backend_provider] + .filter((value): value is string => typeof value === 'string') + .join(' ') + .toLowerCase(); + return ( + provider.slug === appliedProvider || + providerIdentity.includes('codex') || + providerIdentity.includes('claude') + ); + } + + function toggleProvider(provider: ModelOptionProvider) { + const current = providerIsExpanded(provider); + setProviderExpansion((values) => ({ ...values, [provider.slug]: !current })); + } + const applySelection = useCallback( async (provider: ModelOptionProvider, model: string) => { setPending(null); @@ -257,6 +336,10 @@ function ModelPickerPanel({ function chooseModel(provider: ModelOptionProvider, model: string) { if (modelIsUnavailable(provider, model)) return; + if (provider.slug === safeOptions.provider && model === safeOptions.model) { + requestProfileDefault(); + return; + } const changed = provider.slug !== effectiveProvider || model !== effectiveModel; const hasTraffic = thread.messages.length > 0 || Boolean(thread.lastResponseId); if (changed && hasTraffic) { @@ -322,161 +405,188 @@ function ModelPickerPanel({ ) : null} - - + - [ - styles.defaultAction, - { - backgroundColor: override ? colors.backgroundElement : colors.backgroundSelected, - borderColor: colors.border, - opacity: pressed ? 0.72 : 1, - }, - ]}> - - {options?.provider && options?.model - ? `Use profile default · ${options.provider}/${options.model}` - : 'Use profile default'} - - {!override ? : null} - - {groups.map(({ provider, models }) => { - const providerCaps = selectedCapabilities(safeOptions, provider.slug, null); + const expanded = providerIsExpanded(provider); + const narrowed = search.trim().length > 0; return ( - - - {provider.name} - {provider.authenticated === false ? ( - - ) : null} - {provider.free_tier === true ? : null} - {typeof provider.warning === 'string' && provider.warning.trim() ? ( - + + toggleProvider(provider)} + style={({ pressed }) => [styles.groupHeader, { opacity: pressed ? 0.6 : 1 }]}> + + + {(provider.name.trim()[0] ?? provider.slug.trim()[0] ?? '•').toUpperCase()} + + + + {provider.name} + + {!narrowed && !expanded ? ( + {models.length} ) : null} - {provider.is_user_defined === true ? ( - + {!narrowed ? ( + + {expanded ? '⌃' : '⌄'} + ) : null} - {!providerCaps ? : null} - - {models.map((model) => { + + {expanded ? models.map((model, index) => { const unavailable = modelIsUnavailable(provider, model); const selected = provider.slug === effectiveProvider && model === effectiveModel; - const caps = selectedCapabilities(safeOptions, provider.slug, model); - const pricing = modelPricingFor(provider, model); - const cost = modelCostLabel(pricing); - const badges = modelCapabilityBadges(caps); + const isDefault = + provider.slug === safeOptions.provider && model === safeOptions.model; + const first = index === 0; + const last = index === models.length - 1; return ( chooseModel(provider, model)} style={({ pressed }) => [ styles.modelRow, + first ? styles.modelRowFirst : null, + last ? styles.modelRowLast : styles.modelRowDivider, { - backgroundColor: selected ? colors.backgroundSelected : 'transparent', - borderColor: selected ? colors.border : 'transparent', + backgroundColor: colors.panel, + borderColor: colors.border, opacity: unavailable ? 0.4 : pressed ? 0.72 : 1, }, ]}> - - - {model} - {selected ? ( - - ) : null} - - - {cost ?? 'Cost unavailable'} - - - {badges.length ? ( - badges.map((badge) => ( - - )) - ) : ( - - )} - {unavailable ? : null} - - + + {model} + + {isDefault ? : null} + {unavailable ? : null} + + {selected ? ( + + ) : null} ); - })} + }) : null} ); })} {!optionsLoading && groups.length === 0 ? ( - No models match “{search.trim()}”. + No matching models ) : null} - {effortChoices.length ? ( - - Reasoning effort - - {effortChoices.map((choice) => { - const active = (draftEffort ?? override?.reasoningEffort) === choice; - return ( + {effortChoices.length || effectiveCaps?.fast === true ? ( + + + Options + + + {effortChoices.length ? ( + <> { - setDraftEffort(choice); - void updateOverride({ reasoningEffort: choice }); - }} + accessibilityState={{ expanded: reasoningExpanded }} + onPress={() => setReasoningExpanded((expanded) => !expanded)} style={[ - styles.choice, - { - backgroundColor: active ? colors.accent : colors.backgroundElement, - borderColor: colors.border, - }, + styles.optionRow, + effectiveCaps?.fast === true || reasoningExpanded + ? { + borderBottomColor: colors.border, + borderBottomWidth: StyleSheet.hairlineWidth, + } + : null, ]}> - - {choice} + Reasoning effort + + + {draftEffort ?? override?.reasoningEffort ?? 'Default'} + - ); - })} + {reasoningExpanded ? ( + + {effortChoices.map((choice) => { + const active = (draftEffort ?? override?.reasoningEffort) === choice; + return ( + { + setDraftEffort(choice); + setReasoningExpanded(false); + void updateOverride({ reasoningEffort: choice }); + }} + style={[ + styles.choice, + { + backgroundColor: active + ? colors.backgroundSelected + : colors.backgroundElement, + }, + ]}> + {choice} + + ); + })} + + ) : null} + + ) : null} + {effectiveCaps?.fast === true ? ( + + Fast mode + + { + setDraftFast(value); + void updateOverride({ fast: value }); + }} + value={draftFast ?? override?.fast ?? false} + /> + + ) : null} ) : null} - {effectiveCaps?.fast === true ? ( - - Fast mode - { - setDraftFast(value); - void updateOverride({ fast: value }); - }} - value={draftFast ?? override?.fast ?? false} - /> - - ) : null} - {pending ? ( Change model mid-conversation? @@ -756,6 +866,18 @@ const styles = StyleSheet.create({ }, barDot: { borderRadius: 4, height: 7, width: 7 }, barLabel: { flex: 1 }, + inline: { + alignItems: 'center', + alignSelf: 'flex-start', + borderRadius: 999, + borderWidth: StyleSheet.hairlineWidth, + flexDirection: 'row', + gap: Spacing.one, + height: 34, + maxWidth: 190, + paddingHorizontal: Spacing.three, + }, + inlineLabel: { flexShrink: 1 }, modalRoot: { flex: 1 }, modalHeader: { alignItems: 'center', @@ -763,8 +885,12 @@ const styles = StyleSheet.create({ flexDirection: 'row', justifyContent: 'space-between', minHeight: 56, - paddingHorizontal: Spacing.four, + paddingHorizontal: Spacing.three, }, + headerAction: { alignItems: 'center', height: 44, justifyContent: 'center', width: 44 }, + headerBack: { fontSize: 34, fontWeight: '300', lineHeight: 38 }, + headerCheck: { fontSize: 19, fontWeight: '700', lineHeight: 24 }, + headerTitle: { flex: 1, fontSize: 18, textAlign: 'left' }, panelSwitch: { flexDirection: 'row', gap: Spacing.two, paddingHorizontal: Spacing.four, paddingVertical: Spacing.two }, panelTab: { borderRadius: 10, @@ -773,42 +899,61 @@ const styles = StyleSheet.create({ paddingHorizontal: Spacing.three, justifyContent: 'center', }, - pickerContent: { paddingBottom: Spacing.five, paddingHorizontal: Spacing.four, paddingTop: Spacing.two }, + pickerContent: { paddingBottom: Spacing.five, paddingTop: 4 }, usageContent: { gap: Spacing.two, paddingBottom: Spacing.five, paddingHorizontal: Spacing.four, paddingTop: Spacing.two }, - stateRow: { alignItems: 'center', flexDirection: 'row', gap: Spacing.two, paddingVertical: Spacing.two }, - searchBox: { + stateRow: { alignItems: 'center', - borderRadius: 10, - borderWidth: StyleSheet.hairlineWidth, flexDirection: 'row', gap: Spacing.two, - marginBottom: Spacing.two, - paddingHorizontal: Spacing.three, + paddingHorizontal: Spacing.four, + paddingVertical: Spacing.two, }, - searchInput: { flex: 1, fontSize: 14, minHeight: 40, outlineStyle: 'none' } as never, - defaultAction: { + searchBox: { alignItems: 'center', - borderRadius: 10, - borderWidth: StyleSheet.hairlineWidth, + borderRadius: 12, flexDirection: 'row', - gap: Spacing.two, - justifyContent: 'space-between', - marginBottom: Spacing.three, + marginBottom: 8, + marginHorizontal: Spacing.four, + marginTop: 12, + paddingHorizontal: Spacing.four, + }, + searchInput: { flex: 1, fontSize: 16, minHeight: 44, outlineStyle: 'none' } as never, + providerSection: { width: '100%' }, + groupHeader: { + alignItems: 'center', + flexDirection: 'row', + gap: 8, + marginHorizontal: Spacing.four, + marginTop: 4, minHeight: 44, - paddingHorizontal: Spacing.three, + paddingHorizontal: 4, + paddingTop: 8, }, - groupHeader: { alignItems: 'center', flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.one, marginTop: Spacing.two, paddingHorizontal: Spacing.one }, + providerMark: { + alignItems: 'center', + borderRadius: 8, + height: 22, + justifyContent: 'center', + width: 22, + }, + providerMarkText: { fontSize: 11, lineHeight: 14 }, + providerName: { flex: 1 }, + disclosure: { fontSize: 16, lineHeight: 18, width: 18 }, modelRow: { - borderRadius: 10, - borderWidth: StyleSheet.hairlineWidth, - marginBottom: Spacing.one, - minHeight: 56, - paddingHorizontal: Spacing.three, - paddingVertical: Spacing.two, + alignItems: 'center', + flexDirection: 'row', + gap: 8, + marginHorizontal: Spacing.four, + minHeight: 44, + paddingHorizontal: Spacing.four, + paddingVertical: 8, }, - modelCopy: { gap: 2 }, - modelTitleRow: { alignItems: 'center', flexDirection: 'row', gap: Spacing.two, justifyContent: 'space-between' }, - badgeRow: { alignItems: 'center', flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.one, marginTop: 2 }, + modelRowFirst: { borderTopLeftRadius: 16, borderTopRightRadius: 16 }, + modelRowLast: { borderBottomLeftRadius: 16, borderBottomRightRadius: 16 }, + modelRowDivider: { borderBottomWidth: StyleSheet.hairlineWidth }, + modelName: { flexShrink: 1, fontSize: 16, fontWeight: '600', lineHeight: 20 }, + modelRowSpacer: { flex: 1 }, + modelCheck: { fontSize: 16, fontWeight: '700', lineHeight: 20 }, badge: { borderRadius: 6, borderWidth: StyleSheet.hairlineWidth, @@ -816,19 +961,27 @@ const styles = StyleSheet.create({ paddingHorizontal: 6, paddingVertical: 1, }, - emptyModels: { paddingVertical: Spacing.three, textAlign: 'center' }, - settingBlock: { - borderRadius: 12, - borderWidth: StyleSheet.hairlineWidth, + emptyModels: { paddingHorizontal: Spacing.four, paddingVertical: 56, textAlign: 'center' }, + optionsSection: { marginTop: 8, paddingBottom: 12 }, + sectionLabel: { paddingBottom: 8, paddingHorizontal: 20, paddingTop: 8 }, + optionsCard: { borderRadius: 16, marginHorizontal: Spacing.four, overflow: 'hidden' }, + optionRow: { + alignItems: 'center', + flexDirection: 'row', + gap: 8, + minHeight: 48, + paddingHorizontal: Spacing.four, + paddingVertical: 6, + }, + reasoningChoices: { + flexDirection: 'row', + flexWrap: 'wrap', gap: Spacing.two, - marginTop: Spacing.three, padding: Spacing.three, }, - choiceRow: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.two }, choice: { alignItems: 'center', borderRadius: 9, - borderWidth: StyleSheet.hairlineWidth, minHeight: 34, justifyContent: 'center', paddingHorizontal: Spacing.three, diff --git a/apps/mobile/src/components/t3-ui.tsx b/apps/mobile/src/components/t3-ui.tsx index acb6406..281bd72 100644 --- a/apps/mobile/src/components/t3-ui.tsx +++ b/apps/mobile/src/components/t3-ui.tsx @@ -178,7 +178,7 @@ export function EmptyState({ const colors = useT3Theme(); return ( - {loading ? : } + {loading ? : null} {title} {detail} {action} @@ -246,7 +246,6 @@ const styles = StyleSheet.create({ justifyContent: 'center', padding: T3Spacing.huge, }, - emptyMark: { borderRadius: 16, height: 48, marginBottom: T3Spacing.xs, width: 48 }, emptyTitle: { fontFamily: T3Typography.bold, fontSize: 18, lineHeight: 23, textAlign: 'center' }, emptyDetail: { fontSize: 14, lineHeight: 19, maxWidth: 360, textAlign: 'center' }, }); diff --git a/apps/mobile/src/components/ui/collapsible.tsx b/apps/mobile/src/components/ui/collapsible.tsx index d0d745b..d1180a4 100644 --- a/apps/mobile/src/components/ui/collapsible.tsx +++ b/apps/mobile/src/components/ui/collapsible.tsx @@ -1,4 +1,3 @@ -import { SymbolView } from 'expo-symbols'; import { PropsWithChildren, useState } from 'react'; import { Pressable, StyleSheet } from 'react-native'; import Animated, { FadeIn } from 'react-native-reanimated'; @@ -6,25 +5,16 @@ import Animated, { FadeIn } from 'react-native-reanimated'; import { ThemedText } from '@/components/themed-text'; import { ThemedView } from '@/components/themed-view'; import { Spacing } from '@/constants/theme'; -import { useTheme } from '@/hooks/use-theme'; export function Collapsible({ children, title }: PropsWithChildren & { title: string }) { const [isOpen, setIsOpen] = useState(false); - const theme = useTheme(); - return ( [styles.heading, pressed && styles.pressedHeading]} onPress={() => setIsOpen((value) => !value)}> - + {isOpen ? '⌄' : '›'} {title} diff --git a/apps/mobile/src/features/command-center/hermes-command-center-screen.tsx b/apps/mobile/src/features/command-center/hermes-command-center-screen.tsx index e0df78e..e498a58 100644 --- a/apps/mobile/src/features/command-center/hermes-command-center-screen.tsx +++ b/apps/mobile/src/features/command-center/hermes-command-center-screen.tsx @@ -1,6 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'expo-router'; -import { SymbolView } from 'expo-symbols'; import { useMemo, useState } from 'react'; import type { ReactNode } from 'react'; import { Alert, Pressable, ScrollView, StyleSheet, View } from 'react-native'; @@ -150,8 +149,8 @@ export function HermesCommandCenterScreen({ connection }: { connection: AgentCon void refresh()} - style={({ pressed }) => [styles.iconButton, { backgroundColor: colors.subtleStrong, opacity: pressed ? 0.55 : 1 }]}> - + style={({ pressed }) => [styles.refreshButton, { backgroundColor: colors.subtleStrong, opacity: pressed ? 0.55 : 1 }]}> + Refresh {environments.length > 1 ? ( @@ -740,7 +739,8 @@ const styles = StyleSheet.create({ heroTitle: { alignItems: 'center', flexDirection: 'row', gap: T3Spacing.md }, title: { fontFamily: T3Typography.bold, fontSize: 26, lineHeight: 32 }, caption: { fontSize: 13, lineHeight: 18 }, - iconButton: { alignItems: 'center', borderRadius: T3Radius.medium, height: 42, justifyContent: 'center', width: 42 }, + refreshButton: { alignItems: 'center', borderRadius: T3Radius.pill, justifyContent: 'center', minHeight: 38, paddingHorizontal: T3Spacing.md }, + refreshLabel: { fontFamily: T3Typography.bold, fontSize: 12, lineHeight: 16 }, chips: { flexDirection: 'row', gap: T3Spacing.sm }, chip: { borderRadius: T3Radius.pill, maxWidth: 240, paddingHorizontal: T3Spacing.lg, paddingVertical: 9 }, environmentChip: { alignItems: 'center', borderRadius: T3Radius.pill, borderWidth: StyleSheet.hairlineWidth, flexDirection: 'row', gap: T3Spacing.sm, maxWidth: 220, paddingHorizontal: T3Spacing.md, paddingVertical: 8 }, diff --git a/apps/mobile/src/features/connection/environments-screen.tsx b/apps/mobile/src/features/connection/environments-screen.tsx index 4225b28..83d0eb2 100644 --- a/apps/mobile/src/features/connection/environments-screen.tsx +++ b/apps/mobile/src/features/connection/environments-screen.tsx @@ -1,6 +1,5 @@ import { useQuery } from '@tanstack/react-query'; import { useRouter } from 'expo-router'; -import { SymbolView } from 'expo-symbols'; import { useState } from 'react'; import { Alert, Platform, Pressable, ScrollView, StyleSheet, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -39,13 +38,6 @@ export function EnvironmentsScreen() { ) : ( - - - No environments connected Add a Hermes machine to access its conversations and tools. @@ -166,12 +158,9 @@ function EnvironmentRow({ active, connection }: { active: boolean; connection: A {statusLabel} - + + {expanded ? '⌃' : '⌄'} + {expanded ? ( @@ -256,13 +245,6 @@ const styles = StyleSheet.create({ }, divider: { height: StyleSheet.hairlineWidth, marginLeft: 42 }, emptyCard: { alignItems: 'center', gap: T3Spacing.md, padding: T3Spacing.xxl }, - emptyIcon: { - alignItems: 'center', - borderRadius: T3Radius.medium, - height: 50, - justifyContent: 'center', - width: 50, - }, emptyTitle: { fontFamily: T3Typography.bold, fontSize: 17, textAlign: 'center' }, emptyDetail: { fontSize: 13, lineHeight: 19, textAlign: 'center' }, note: { fontSize: 12, lineHeight: 17, textAlign: 'center' }, @@ -278,6 +260,7 @@ const styles = StyleSheet.create({ rowName: { flexShrink: 1, fontFamily: T3Typography.bold, fontSize: 15, lineHeight: 20 }, rowURL: { fontSize: 12, lineHeight: 17 }, rowStatus: { fontSize: 11, lineHeight: 15 }, + expandLabel: { fontFamily: T3Typography.bold, fontSize: 17, lineHeight: 21 }, activeBadge: { borderRadius: T3Radius.pill, paddingHorizontal: T3Spacing.sm, paddingVertical: 2 }, activeText: { fontFamily: T3Typography.bold, fontSize: 10, lineHeight: 14 }, expanded: { borderTopWidth: StyleSheet.hairlineWidth, gap: T3Spacing.md, padding: T3Spacing.lg }, diff --git a/apps/mobile/src/features/connection/relay-environments-screen.tsx b/apps/mobile/src/features/connection/relay-environments-screen.tsx index 0a6d4c3..0bcfc38 100644 --- a/apps/mobile/src/features/connection/relay-environments-screen.tsx +++ b/apps/mobile/src/features/connection/relay-environments-screen.tsx @@ -1,7 +1,6 @@ import { useClerk } from '@clerk/expo'; import { useMutation, useQuery } from '@tanstack/react-query'; import * as Clipboard from 'expo-clipboard'; -import { SymbolView } from 'expo-symbols'; import { useEffect, useState } from 'react'; import { RefreshControl, ScrollView, StyleSheet, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -243,13 +242,6 @@ function RelayEnvironmentsContent({ ) : ( - - - No Relay environments yet Generate an enrollment code below to attach a Hermes machine. @@ -410,13 +402,6 @@ const styles = StyleSheet.create({ messageCard: { alignItems: 'center', gap: T3Spacing.md, padding: T3Spacing.xxl }, messageTitle: { fontFamily: T3Typography.bold, fontSize: 16, textAlign: 'center' }, message: { fontSize: 13, lineHeight: 19, textAlign: 'center' }, - emptyIcon: { - alignItems: 'center', - borderRadius: T3Radius.medium, - height: 48, - justifyContent: 'center', - width: 48, - }, formCard: { gap: T3Spacing.md, padding: T3Spacing.lg }, cardTitle: { fontFamily: T3Typography.bold, fontSize: 16, lineHeight: 21 }, cardDetail: { fontSize: 13, lineHeight: 19 }, diff --git a/apps/mobile/src/features/connection/relay-sign-in-screen.tsx b/apps/mobile/src/features/connection/relay-sign-in-screen.tsx index d4c935a..cf822b6 100644 --- a/apps/mobile/src/features/connection/relay-sign-in-screen.tsx +++ b/apps/mobile/src/features/connection/relay-sign-in-screen.tsx @@ -1,7 +1,6 @@ import { useAuth, useClerk, useUser } from '@clerk/expo'; import * as Device from 'expo-device'; import { Stack, useRouter } from 'expo-router'; -import { SymbolView } from 'expo-symbols'; import type { ReactNode } from 'react'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { @@ -353,7 +352,7 @@ function RelayScreenFrame({ onPress={close} style={({ pressed }) => [styles.headerIcon, { opacity: pressed ? 0.5 : 1 }]} > - + Close ), title: 'Connect Relay', @@ -370,9 +369,6 @@ function RelayScreenFrame({ showsVerticalScrollIndicator={false} > - - - {title} {detail} @@ -433,7 +429,8 @@ function explainRelayError(reason: unknown) { const styles = StyleSheet.create({ safe: { flex: 1 }, - headerIcon: { alignItems: 'center', height: 40, justifyContent: 'center', width: 40 }, + headerIcon: { alignItems: 'center', height: 40, justifyContent: 'center', minWidth: 58 }, + headerClose: { fontFamily: T3Typography.bold, fontSize: 13, lineHeight: 17 }, content: { alignSelf: 'center', gap: T3Spacing.lg, @@ -448,14 +445,6 @@ const styles = StyleSheet.create({ marginBottom: T3Spacing.sm, paddingHorizontal: T3Spacing.md, }, - heroIcon: { - alignItems: 'center', - borderRadius: T3Radius.medium, - height: 52, - justifyContent: 'center', - marginBottom: T3Spacing.xs, - width: 52, - }, title: { fontFamily: T3Typography.bold, fontSize: 23, diff --git a/apps/mobile/src/features/files/hermes-files-screen.tsx b/apps/mobile/src/features/files/hermes-files-screen.tsx index ecea9ef..ecbe6ef 100644 --- a/apps/mobile/src/features/files/hermes-files-screen.tsx +++ b/apps/mobile/src/features/files/hermes-files-screen.tsx @@ -1,5 +1,4 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { SymbolView } from 'expo-symbols'; import { useState } from 'react'; import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -47,7 +46,7 @@ export function HermesFilesScreen({ connection }: { connection: AgentConnection disabled={!currentPath} onPress={() => setPath(parentPath(currentPath))} style={({ pressed }) => [styles.upButton, { opacity: pressed ? 0.5 : currentPath ? 1 : 0.35 }]}> - + Up {currentPath ?? 'Workspace'} @@ -101,11 +100,9 @@ function FileRow({ entry, onPress }: { entry: HermesFileEntry; onPress: () => vo [styles.fileRow, { opacity: pressed ? 0.55 : 1 }]}> - + + {entry.dir ? 'DIR' : 'FILE'} + {entry.name} @@ -235,7 +232,8 @@ const styles = StyleSheet.create({ paddingHorizontal: T3Spacing.lg, paddingVertical: T3Spacing.sm, }, - upButton: { alignItems: 'center', height: 38, justifyContent: 'center', width: 38 }, + upButton: { alignItems: 'center', height: 38, justifyContent: 'center', paddingHorizontal: T3Spacing.sm }, + upLabel: { fontFamily: T3Typography.bold, fontSize: 13, lineHeight: 17 }, path: { flex: 1, fontFamily: T3Typography.mono, fontSize: 12, lineHeight: 17 }, fileList: { alignSelf: 'center', @@ -252,6 +250,12 @@ const styles = StyleSheet.create({ minHeight: 58, paddingVertical: T3Spacing.sm, }, + fileKind: { + fontFamily: T3Typography.bold, + fontSize: 10, + lineHeight: 14, + minWidth: 28, + }, fileCopy: { flex: 1 }, fileName: { fontFamily: T3Typography.medium, fontSize: 15, lineHeight: 20 }, fileSize: { fontSize: 11, lineHeight: 15 }, diff --git a/apps/mobile/src/features/home/hermes-home-screen.tsx b/apps/mobile/src/features/home/hermes-home-screen.tsx index d8719d1..8b88d72 100644 --- a/apps/mobile/src/features/home/hermes-home-screen.tsx +++ b/apps/mobile/src/features/home/hermes-home-screen.tsx @@ -1,9 +1,12 @@ import { useQuery } from '@tanstack/react-query'; -import { useRouter } from 'expo-router'; -import { SymbolView } from 'expo-symbols'; +import { useRouter, type Href } from 'expo-router'; import { useEffect, useMemo, useState } from 'react'; import { FlatList, + KeyboardAvoidingView, + Modal, + PanResponder, + Platform, Pressable, RefreshControl, ScrollView, @@ -13,11 +16,13 @@ import { } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { SessionModelControls } from '@/components/session-model-controls'; import { AppText, AppTextInput, EmptyState, StatusDot } from '@/components/t3-ui'; import { SPLIT_LAYOUT_MIN_WIDTH, T3Radius, T3Spacing, T3Typography } from '@/constants/t3-theme'; import { useT3Theme } from '@/hooks/use-t3-theme'; import { getHealth, + getModelOptions, listSessions, searchSessions, type AgentConnection, @@ -32,14 +37,28 @@ import { type HermesProfile, } from '@/lib/profiles'; import { resolveBrioDeepLink } from '@/lib/profiles-model'; +import { modelIncompatibilities, selectedCapabilities } from '@/lib/session-runtime'; +import { useComposerStore } from '@/state/composer-store'; +import type { ChatModelOverride, ChatThread } from '@/state/chat-thread-model'; import { useDeepLinkStore } from '@/state/deep-link-store'; import { useProfileStore } from '@/state/profile-store'; +const SWIPE_DISTANCE = 64; + export function HermesHomeScreen({ connection }: { connection: AgentConnection }) { const colors = useT3Theme(); const router = useRouter(); const { width } = useWindowDimensions(); + const [historyOpen, setHistoryOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); const [search, setSearch] = useState(''); + const [startError, setStartError] = useState(''); + const [starting, setStarting] = useState(false); + const [composerFocused, setComposerFocused] = useState(false); + const [modelPickerOpen, setModelPickerOpen] = useState(false); + const [newThreadModels, setNewThreadModels] = useState< + Record + >({}); const agentId = environmentId(connection); const storedProfiles = useProfileStore((state) => state.activeProfiles); const setActiveProfile = useProfileStore((state) => state.setActiveProfile); @@ -61,6 +80,14 @@ export function HermesHomeScreen({ connection }: { connection: AgentConnection } : profilesQuery.data ? profileName(profilesQuery.data.active) : DEFAULT_PROFILE_NAME; + const activeProfileData = profiles.find((profile) => profile.name === activeProfile); + const composerKey = `${connection.id}:${activeProfile}:new`; + const newThreadModel = newThreadModels[composerKey]; + const composerHydrated = useComposerStore((state) => state.hydrated); + const draft = useComposerStore((state) => state.drafts[composerKey] ?? ''); + const setDraft = useComposerStore((state) => state.setDraft); + const enqueueDraft = useComposerStore((state) => state.enqueueDraft); + useEffect(() => { if (!pendingDeepLink || !profilesQuery.data) return; const resolved = resolveBrioDeepLink(pendingDeepLink, agentId, profiles); @@ -87,11 +114,18 @@ export function HermesHomeScreen({ connection }: { connection: AgentConnection } router, setActiveProfile, ]); + const health = useQuery({ queryKey: ['home-health', connection.id, connection.url], queryFn: () => getHealth(connection), refetchInterval: 15_000, }); + const modelOptions = useQuery({ + queryKey: ['model-options', connection.id, connection.url, activeProfile], + queryFn: () => getModelOptions(connection, false, activeProfile), + staleTime: 5 * 60_000, + retry: 1, + }); const sessions = useQuery({ queryKey: ['sessions', connection.id, connection.url, activeProfile], queryFn: () => listSessions(connection, 100, activeProfile), @@ -118,189 +152,419 @@ export function HermesHomeScreen({ connection }: { connection: AgentConnection } ); }, [resultIds, search, sessions.data?.sessions]); const split = width >= SPLIT_LAYOUT_MIN_WIDTH; - const threadPath = (sessionId: string) => - `/thread/${encodeURIComponent(sessionId)}${ - isNamedProfile(activeProfile) ? `?profile=${encodeURIComponent(activeProfile)}` : '' - }` as const; - const startNewTask = () => { - // `new` tells the thread screen to create a Hermes gateway session on the - // first prompt. A made-up stored session id is treated as a resume request - // by Hermes and leaves a fresh conversation in an error state. - router.push(threadPath('new')); + const threadPath = (sessionId: string, override?: ChatModelOverride) => { + const params: string[] = []; + if (isNamedProfile(activeProfile)) { + params.push(`profile=${encodeURIComponent(activeProfile)}`); + } + if (override) { + params.push(`provider=${encodeURIComponent(override.provider)}`); + params.push(`model=${encodeURIComponent(override.model)}`); + if (override.reasoningEffort) { + params.push(`effort=${encodeURIComponent(override.reasoningEffort)}`); + } + if (typeof override.fast === 'boolean') params.push(`fast=${String(override.fast)}`); + } + return `/thread/${encodeURIComponent(sessionId)}${params.length ? `?${params.join('&')}` : ''}` as const; + }; + + const homeSwipe = useMemo( + () => + createHorizontalSwipe({ + onLeft: () => setHistoryOpen(true), + onRight: () => setSettingsOpen(true), + }), + [], + ); + const historySwipe = useMemo( + () => createHorizontalSwipe({ onRight: () => setHistoryOpen(false) }), + [], + ); + const settingsSwipe = useMemo( + () => createHorizontalSwipe({ onLeft: () => setSettingsOpen(false) }), + [], + ); + + const startNewTask = async () => { + if (!composerHydrated || !draft.trim() || starting) return; + setStartError(''); + setStarting(true); + try { + const queued = await enqueueDraft(composerKey, 'queue', newThreadModel); + if (!queued) { + setStartError('Write a message for Hermes before starting.'); + return; + } + router.push(threadPath('new', newThreadModel)); + } catch (reason) { + setStartError(reason instanceof Error ? reason.message : 'Could not start this conversation.'); + } finally { + setStarting(false); + } + }; + const openThread = (sessionId: string) => { + setHistoryOpen(false); + router.push(threadPath(sessionId)); + }; + const openTool = (href: Href) => { + setSettingsOpen(false); + router.push(href); + }; + const agentStatus = activeProfileData?.gateway_running === false + ? 'offline' + : health.isError + ? 'error' + : health.data?.hermes_ok + ? 'online' + : 'busy'; + const newThread: ChatThread = { + id: 'new', + profile: activeProfile, + title: 'New Thread', + createdAt: 0, + updatedAt: 0, + messages: [], + ...(newThreadModel ? { modelOverride: newThreadModel } : {}), }; + const composerDestination = + activeProfileData?.alias_name?.trim() || connection.name?.trim() || 'Hermes'; + const selectedModelBlocked = Boolean( + newThreadModel && + modelIncompatibilities( + selectedCapabilities( + modelOptions.data ?? { model: null, provider: null, providers: [] }, + newThreadModel.provider, + newThreadModel.model, + ), + { vision: false, tools: true }, + ).length, + ); + const canStart = + composerHydrated && Boolean(draft.trim()) && !starting && !selectedModelBlocked; + const composerExpanded = composerFocused || modelPickerOpen; return ( - - - + + + router.push('/environments')} - style={({ pressed }) => [styles.environmentButton, { opacity: pressed ? 0.6 : 1 }]} - > - Brio - - - - {connection.name || 'Hermes'} - - - + onPress={() => setHistoryOpen(true)} + style={({ pressed }) => [styles.backButton, { opacity: pressed ? 0.5 : 1 }]}> + - - router.push('/profiles')} - /> - router.push('/command-center')} - /> - router.push('/files')} - /> - router.push('/settings')} - /> - + New Thread - {profiles.length > 1 ? ( - - {profiles.map((profile) => { - const selected = profile.name === activeProfile; - return ( + + + + + {startError ? ( + {startError} + ) : null} + + + setDraft(composerKey, value)} + onBlur={() => setComposerFocused(false)} + onFocus={() => setComposerFocused(true)} + placeholder={`Describe a coding task in ${composerDestination}`} + style={[ + styles.promptInput, + composerExpanded ? styles.promptInputExpanded : styles.promptInputCollapsed, + { backgroundColor: 'transparent', borderColor: 'transparent' }, + ]} + textAlignVertical="top" + value={draft} + /> + {!composerExpanded ? ( - void setActiveProfile( - agentId, - isNamedProfile(profile.name) ? profile.name : undefined, - ) + disabled={!canStart} + onPress={() => void startNewTask()} + style={({ pressed }) => [ + styles.sendButton, + { + backgroundColor: canStart ? colors.primary : colors.subtleStrong, + opacity: pressed ? 0.65 : 1, + }, + ]}> + + {starting ? '…' : '↑'} + + + ) : null} + + {composerExpanded ? ( + + + setNewThreadModels((current) => ({ ...current, [composerKey]: override })) } + onOpenChange={setModelPickerOpen} + options={modelOptions.data} + optionsError={modelOptions.isError} + optionsLoading={modelOptions.isLoading} + showUsage={false} + thread={newThread} + variant="inline" + /> + + void startNewTask()} style={({ pressed }) => [ - styles.profileChip, + styles.sendButton, { - backgroundColor: selected ? colors.primary : colors.subtleStrong, - borderColor: selected ? colors.primary : colors.border, + backgroundColor: canStart ? colors.primary : colors.subtleStrong, opacity: pressed ? 0.65 : 1, }, ]}> - - {profile.name} + {starting ? '…' : '↑'} - ); - })} - - ) : null} - - + + ) : null} + + + - {sessions.isLoading ? ( - - ) : sessions.isError ? ( - void sessions.refetch()} style={styles.retry}> - Try again - - } - detail={sessions.error instanceof Error ? sessions.error.message : 'The environment is unavailable.'} - title="Environment unavailable" - /> - ) : ( - item.id} - ListEmptyComponent={ - setHistoryOpen(false)} visible={historyOpen}> + + setHistoryOpen(false)} + title="History" + /> + + - } - refreshControl={ - void sessions.refetch()} /> - } - renderItem={({ item }) => ( - router.push(threadPath(item.id))} /> - )} - /> - )} + + item.id} + ListEmptyComponent={ + sessions.isLoading ? ( + + ) : sessions.isError ? ( + void sessions.refetch()} style={styles.retry}> + Try again + + } + detail={ + sessions.error instanceof Error + ? sessions.error.message + : 'The environment is unavailable.' + } + title="History unavailable" + /> + ) : ( + + ) + } + refreshControl={ + void sessions.refetch()} + /> + } + renderItem={({ item }) => ( + openThread(item.id)} /> + )} + /> + + + + setSettingsOpen(false)} visible={settingsOpen}> + + setSettingsOpen(false)} + title="Settings & tools" + /> + + openTool('/environments')} + style={({ pressed }) => [ + styles.environmentCard, + { + backgroundColor: colors.card, + borderColor: colors.border, + opacity: pressed ? 0.6 : 1, + }, + ]}> + + + {connection.name || 'Hermes'} + + {activeProfile} + {activeProfileData?.model ? ` · ${activeProfileData.model}` : ''} + + + + + + openTool('/profiles')} + /> + openTool('/command-center')} + /> + openTool('/files')} + /> + openTool('/settings')} + /> + + + + + + ); +} +function createHorizontalSwipe({ + onLeft, + onRight, +}: { + onLeft?: () => void; + onRight?: () => void; +}) { + const wantsHorizontalSwipe = (_: unknown, gesture: { dx: number; dy: number }) => + Math.abs(gesture.dx) > 18 && Math.abs(gesture.dx) > Math.abs(gesture.dy) * 1.4; + return PanResponder.create({ + onMoveShouldSetPanResponder: wantsHorizontalSwipe, + onMoveShouldSetPanResponderCapture: wantsHorizontalSwipe, + onPanResponderRelease: (_, gesture) => { + if (gesture.dx <= -SWIPE_DISTANCE) onLeft?.(); + if (gesture.dx >= SWIPE_DISTANCE) onRight?.(); + }, + onPanResponderTerminationRequest: () => true, + }); +} + +function PanelHeader({ + detail, + onClose, + title, +}: { + detail: string; + onClose: () => void; + title: string; +}) { + const colors = useT3Theme(); + return ( + + + {title} + {detail} + [ - styles.fab, - { backgroundColor: colors.primary, opacity: pressed ? 0.72 : 1 }, + styles.chatButton, + { backgroundColor: colors.subtleStrong, opacity: pressed ? 0.6 : 1 }, ]}> - - {split ? ( - New task - ) : null} + Chat - + ); } -function HeaderButton({ - accessibilityLabel, - icon, - onPress, -}: { - accessibilityLabel: string; - icon: 'folder' | 'gearshape' | 'person.2' | 'square.grid.2x2'; - onPress: () => void; -}) { +function MenuRow({ detail, label, onPress }: { detail: string; label: string; onPress: () => void }) { const colors = useT3Theme(); return ( [ - styles.headerButton, - { backgroundColor: colors.subtleStrong, opacity: pressed ? 0.55 : 1 }, + styles.menuRow, + { borderTopColor: colors.separator, opacity: pressed ? 0.55 : 1 }, ]}> - + + {label} + {detail} + + ); } @@ -317,9 +581,6 @@ function SessionRow({ session, onPress }: { session: HermesSession; onPress: () styles.sessionRow, { borderBottomColor: colors.separator, opacity: pressed ? 0.55 : 1 }, ]}> - - - @@ -350,56 +611,93 @@ function formatRelativeTime(timestamp: number) { const styles = StyleSheet.create({ safe: { flex: 1 }, - header: { + threadHeader: { + alignItems: 'center', borderBottomWidth: StyleSheet.hairlineWidth, - gap: T3Spacing.lg, - paddingBottom: T3Spacing.lg, - paddingHorizontal: T3Spacing.xl, - paddingTop: T3Spacing.md, + flexDirection: 'row', + minHeight: 62, + paddingHorizontal: T3Spacing.lg, }, - brandRow: { alignItems: 'center', flexDirection: 'row', justifyContent: 'space-between' }, - environmentButton: { flexShrink: 1 }, - brand: { fontFamily: T3Typography.bold, fontSize: 26, lineHeight: 32 }, - environmentRow: { alignItems: 'center', flexDirection: 'row', gap: 7 }, - environment: { fontSize: 13, lineHeight: 17, maxWidth: 220 }, - headerActions: { flexDirection: 'row', gap: T3Spacing.sm }, - profileChips: { gap: T3Spacing.sm }, - profileChip: { + backButton: { alignItems: 'center', - borderRadius: T3Radius.pill, + height: 44, + justifyContent: 'center', + marginLeft: -8, + width: 44, + }, + backChevron: { fontSize: 42, fontFamily: T3Typography.regular, lineHeight: 42 }, + threadTitle: { + fontFamily: T3Typography.bold, + fontSize: 22, + letterSpacing: -0.25, + lineHeight: 28, + }, + conversationCanvas: { flex: 1 }, + composerDock: { paddingBottom: T3Spacing.sm, paddingHorizontal: T3Spacing.lg, paddingTop: T3Spacing.sm }, + composerDockWide: { alignSelf: 'center', maxWidth: 760, width: '100%' }, + promptComposer: { borderWidth: StyleSheet.hairlineWidth, - flexDirection: 'row', - gap: T3Spacing.sm, - maxWidth: 180, - paddingHorizontal: T3Spacing.md, - paddingVertical: 7, + overflow: 'hidden', }, - profileLabel: { fontFamily: T3Typography.medium, fontSize: 13, lineHeight: 17 }, - headerButton: { + promptComposerCollapsed: { + borderRadius: T3Radius.pill, + minHeight: 54, + paddingBottom: 5, + paddingLeft: 0, + paddingRight: 5, + paddingTop: 5, + }, + promptComposerExpanded: { + borderRadius: 26, + minHeight: 140, + paddingBottom: 6, + paddingHorizontal: 14, + paddingTop: 14, + }, + collapsedInputRow: { alignItems: 'center', flexDirection: 'row' }, + expandedInputRow: { minHeight: 78 }, + promptInput: { + flex: 1, + fontSize: 17, + lineHeight: 24, + }, + promptInputCollapsed: { borderWidth: 0, height: 36, minHeight: 36, paddingHorizontal: 18, paddingVertical: 4 }, + promptInputExpanded: { borderWidth: 0, maxHeight: 150, minHeight: 78, paddingHorizontal: T3Spacing.xs, paddingTop: T3Spacing.xs }, + promptFooter: { alignItems: 'center', flexDirection: 'row', gap: T3Spacing.md }, + footerSpacer: { flex: 1 }, + sendButton: { alignItems: 'center', - borderRadius: T3Radius.medium, - height: 42, + borderRadius: T3Radius.pill, + height: 44, justifyContent: 'center', - width: 42, + width: 44, }, - search: { minHeight: 42, paddingVertical: 8 }, - listContent: { paddingBottom: 112, paddingHorizontal: T3Spacing.xl }, - listContentWide: { alignSelf: 'center', maxWidth: 760, width: '100%' }, - emptyList: { flexGrow: 1 }, - sessionRow: { + sendLabel: { fontFamily: T3Typography.bold, fontSize: 21, lineHeight: 24 }, + startError: { fontSize: 13, lineHeight: 17, paddingBottom: T3Spacing.sm, paddingHorizontal: T3Spacing.xs }, + panel: { flex: 1 }, + panelHeader: { alignItems: 'center', borderBottomWidth: StyleSheet.hairlineWidth, flexDirection: 'row', - gap: T3Spacing.md, - minHeight: 76, + paddingHorizontal: T3Spacing.xl, paddingVertical: T3Spacing.md, }, - sessionIcon: { + panelHeaderCopy: { flex: 1 }, + panelTitle: { fontFamily: T3Typography.bold, fontSize: 21, lineHeight: 27 }, + panelDetail: { fontSize: 12, lineHeight: 16 }, + chatButton: { borderRadius: T3Radius.pill, paddingHorizontal: 14, paddingVertical: 9 }, + chatButtonLabel: { fontFamily: T3Typography.bold, fontSize: 13, lineHeight: 17 }, + panelSearch: { paddingHorizontal: T3Spacing.xl, paddingTop: T3Spacing.lg }, + panelWide: { alignSelf: 'center', maxWidth: 760, width: '100%' }, + search: { minHeight: 44, paddingVertical: 8 }, + historyContent: { flexGrow: 1, paddingBottom: T3Spacing.huge, paddingHorizontal: T3Spacing.xl }, + emptyHistory: { flexGrow: 1 }, + sessionRow: { alignItems: 'center', - borderRadius: T3Radius.medium, - height: 42, - justifyContent: 'center', - width: 42, + borderBottomWidth: StyleSheet.hairlineWidth, + flexDirection: 'row', + minHeight: 76, + paddingVertical: T3Spacing.md, }, sessionCopy: { flex: 1, gap: 2 }, sessionTitleRow: { alignItems: 'baseline', flexDirection: 'row', gap: T3Spacing.sm }, @@ -407,20 +705,37 @@ const styles = StyleSheet.create({ sessionDate: { fontSize: 12, lineHeight: 16 }, sessionMeta: { fontSize: 13, lineHeight: 17 }, retry: { padding: T3Spacing.md }, - fab: { + settingsContent: { + gap: T3Spacing.lg, + padding: T3Spacing.xl, + paddingBottom: T3Spacing.huge, + }, + environmentCard: { alignItems: 'center', - borderRadius: T3Radius.pill, - bottom: T3Spacing.xxl, + borderRadius: T3Radius.large, + borderWidth: StyleSheet.hairlineWidth, + flexDirection: 'row', + gap: T3Spacing.md, + minHeight: 76, + padding: T3Spacing.lg, + }, + environmentCopy: { flex: 1 }, + environmentName: { fontFamily: T3Typography.bold, fontSize: 16, lineHeight: 21 }, + environmentMeta: { fontSize: 12, lineHeight: 16 }, + menu: { + borderRadius: T3Radius.large, + borderWidth: StyleSheet.hairlineWidth, + overflow: 'hidden', + paddingHorizontal: T3Spacing.lg, + }, + menuRow: { + alignItems: 'center', + borderTopWidth: StyleSheet.hairlineWidth, flexDirection: 'row', - gap: T3Spacing.sm, - minHeight: 56, - paddingHorizontal: 18, - position: 'absolute', - right: T3Spacing.xxl, - shadowColor: '#000000', - shadowOffset: { height: 8, width: 0 }, - shadowOpacity: 0.2, - shadowRadius: 18, + minHeight: 70, + paddingVertical: T3Spacing.sm, }, - fabLabel: { fontFamily: T3Typography.bold, fontSize: 14 }, + menuCopy: { flex: 1 }, + menuLabel: { fontFamily: T3Typography.medium, fontSize: 15, lineHeight: 20 }, + menuDetail: { fontSize: 12, lineHeight: 16 }, }); diff --git a/apps/mobile/src/features/settings/hermes-settings-screen.tsx b/apps/mobile/src/features/settings/hermes-settings-screen.tsx index fdcd861..6044f55 100644 --- a/apps/mobile/src/features/settings/hermes-settings-screen.tsx +++ b/apps/mobile/src/features/settings/hermes-settings-screen.tsx @@ -146,7 +146,7 @@ export function HermesSettingsScreen({ connection }: { connection: AgentConnecti - Brio · Hermes mobile control plane + Hermes mobile control {panel ? ( diff --git a/apps/mobile/src/features/threads/hermes-thread-screen.tsx b/apps/mobile/src/features/threads/hermes-thread-screen.tsx index 52c399d..f470584 100644 --- a/apps/mobile/src/features/threads/hermes-thread-screen.tsx +++ b/apps/mobile/src/features/threads/hermes-thread-screen.tsx @@ -44,7 +44,12 @@ import { type HermesGatewayState, } from '@/lib/hermes-gateway'; import { isNamedProfile } from '@/lib/profiles'; -import { buildRuntimeModelOptions, normalizeLiveUsage } from '@/lib/session-runtime'; +import { + buildRuntimeModelOptions, + modelIncompatibilities, + normalizeLiveUsage, + selectedCapabilities, +} from '@/lib/session-runtime'; import { useRunStore } from '@/state/run-store'; import { useComposerStore } from '@/state/composer-store'; import { @@ -95,10 +100,12 @@ type GatewayInputRequest = { export function HermesThreadScreen({ connection, + initialModelOverride, profile, routeSessionId, }: { connection: AgentConnection; + initialModelOverride?: ChatModelOverride; profile: string; routeSessionId: string; }) { @@ -110,7 +117,10 @@ export function HermesThreadScreen({ const sessionId = routeSessionId === 'new' ? generatedSessionId : routeSessionId; const runKey = `${connection.id}:${profile}:${sessionId}`; const composerKey = `${connection.id}:${profile}:${routeSessionId}`; - const [modelOverride, setModelOverride] = useState(); + const [modelOverride, setModelOverride] = useState( + initialModelOverride, + ); + const [modelPickerOpen, setModelPickerOpen] = useState(false); const [immediateMessages, setImmediateMessages] = useState([]); const [composerError, setComposerError] = useState(''); const gatewayRef = useRef(null); @@ -483,6 +493,7 @@ export function HermesThreadScreen({ const submit = useMutation({ mutationFn: async (queuedPrompt: QueuedPrompt) => { const input = queuedPrompt.text.trim(); + const promptModelOverride = queuedPrompt.modelOverride ?? modelOverride; if (input.startsWith('/') && queuedPrompt.attachments.length === 0) { const response = await dispatchComposerCommand( connection, @@ -511,12 +522,14 @@ export function HermesThreadScreen({ source: 'brio', title: 'Hermes conversation', ...(isNamedProfile(profile) ? { profile } : {}), - ...(modelOverride?.model ? { model: modelOverride.model } : {}), - ...(modelOverride?.provider ? { provider: modelOverride.provider } : {}), - ...(modelOverride?.reasoningEffort - ? { reasoning_effort: modelOverride.reasoningEffort } + ...(promptModelOverride?.model ? { model: promptModelOverride.model } : {}), + ...(promptModelOverride?.provider ? { provider: promptModelOverride.provider } : {}), + ...(promptModelOverride?.reasoningEffort + ? { reasoning_effort: promptModelOverride.reasoningEffort } + : {}), + ...(typeof promptModelOverride?.fast === 'boolean' + ? { fast: promptModelOverride.fast } : {}), - ...(typeof modelOverride?.fast === 'boolean' ? { fast: modelOverride.fast } : {}), }); target = { runtime: created.session_id, stored: created.stored_session_id }; } else { @@ -595,9 +608,9 @@ export function HermesThreadScreen({ content: message.content, })), ], - model: modelOverride?.model, - provider: modelOverride?.provider, - modelOptions: buildRuntimeModelOptions(modelOverride), + model: promptModelOverride?.model, + provider: promptModelOverride?.provider, + modelOptions: buildRuntimeModelOptions(promptModelOverride), sessionId, profile, composerSessionId: relayExpandsComposer ? sessionId : undefined, @@ -613,9 +626,9 @@ export function HermesThreadScreen({ const result = await startRun(connection, input, { sessionId, - model: modelOverride?.model, - provider: modelOverride?.provider, - modelOptions: buildRuntimeModelOptions(modelOverride), + model: promptModelOverride?.model, + provider: promptModelOverride?.provider, + modelOptions: buildRuntimeModelOptions(promptModelOverride), profile, conversationHistory: (messages.data?.messages ?? []) .filter((message) => @@ -661,9 +674,22 @@ export function HermesThreadScreen({ ), ); if (routeSessionId === 'new') { + const params: string[] = []; + if (isNamedProfile(profile)) params.push(`profile=${encodeURIComponent(profile)}`); + const acceptedModelOverride = queuedPrompt.modelOverride ?? modelOverride; + if (acceptedModelOverride) { + params.push(`provider=${encodeURIComponent(acceptedModelOverride.provider)}`); + params.push(`model=${encodeURIComponent(acceptedModelOverride.model)}`); + if (acceptedModelOverride.reasoningEffort) { + params.push(`effort=${encodeURIComponent(acceptedModelOverride.reasoningEffort)}`); + } + if (typeof acceptedModelOverride.fast === 'boolean') { + params.push(`fast=${String(acceptedModelOverride.fast)}`); + } + } router.replace( `/thread/${encodeURIComponent(result.sessionId ?? sessionId)}${ - isNamedProfile(profile) ? `?profile=${encodeURIComponent(profile)}` : '' + params.length ? `?${params.join('&')}` : '' }`, ); } @@ -795,6 +821,13 @@ export function HermesThreadScreen({ ...(contextBreakdown.data ? { contextBreakdown: contextBreakdown.data } : {}), runtimeSessionId: sessionId, }; + const selectedModelBlocked = Boolean( + modelOverride && + modelIncompatibilities( + selectedCapabilities(modelOptions.data ?? { model: null, provider: null, providers: [] }, modelOverride.provider, modelOverride.model), + { vision: attachments.some((attachment) => attachment.kind === 'image'), tools: true }, + ).length, + ); const changeModelOverride = (override: ChatModelOverride | undefined) => { setModelOverride(override); if (routeSessionId === 'new') return; @@ -831,7 +864,7 @@ export function HermesThreadScreen({ const send = async (deliveryMode: 'queue' | 'redirect') => { if ((!draft.trim() && attachments.length === 0) || !composerHydrated) return; - const queuedPrompt = await enqueueDraft(composerKey, deliveryMode); + const queuedPrompt = await enqueueDraft(composerKey, deliveryMode, modelOverride); if (!queuedPrompt || deliveryMode !== 'redirect' || !active) return; try { const target = gatewaySessionRef.current; @@ -860,7 +893,7 @@ export function HermesThreadScreen({ return ( {messages.isLoading && feed.length === 0 ? ( @@ -928,18 +961,7 @@ export function HermesThreadScreen({ ) : null} - - - - - + {queue.length > 0 ? ( + } onAddAttachment={(attachment) => addAttachment(composerKey, attachment)} onDraftChange={(value) => setDraft(composerKey, value)} onRedo={() => redoDraft(composerKey)} @@ -977,6 +1012,7 @@ export function HermesThreadScreen({ onSend={(mode) => void send(mode)} onUndo={() => undoDraft(composerKey)} profile={profile} + sendDisabled={selectedModelBlocked} sessionId={sessionId} /> @@ -1344,15 +1380,7 @@ const styles = StyleSheet.create({ userBubble: { borderBottomRightRadius: 5, paddingHorizontal: 14, paddingVertical: 10 }, messageText: { fontSize: 15, lineHeight: 23 }, toolName: { fontFamily: T3Typography.bold, fontSize: 11, lineHeight: 15, textTransform: 'uppercase' }, - composerShell: { borderTopWidth: StyleSheet.hairlineWidth, padding: T3Spacing.md }, - modelControls: { - alignSelf: 'center', - borderTopWidth: StyleSheet.hairlineWidth, - maxWidth: CHAT_CONTENT_MAX_WIDTH, - paddingHorizontal: T3Spacing.md, - paddingTop: T3Spacing.sm, - width: '100%', - }, + composerShell: { paddingHorizontal: T3Spacing.lg, paddingVertical: 6 }, composer: { alignSelf: 'center', gap: T3Spacing.sm, diff --git a/apps/mobile/src/lib/session-runtime.ts b/apps/mobile/src/lib/session-runtime.ts index 5a847c6..8b8264c 100644 --- a/apps/mobile/src/lib/session-runtime.ts +++ b/apps/mobile/src/lib/session-runtime.ts @@ -455,7 +455,8 @@ export function modelCostLabel(pricing?: ModelPricing | null): string | null { const input = typeof pricing.input === 'string' ? pricing.input.trim() : ''; const output = typeof pricing.output === 'string' ? pricing.output.trim() : ''; if (!input || !output) return null; - return `$${input} in · $${output} out per Mtok`; + const currency = (value: string) => (value.startsWith('$') ? value : `$${value}`); + return `${currency(input)} in · ${currency(output)} out per Mtok`; } export type ReasoningEffortChoice = 'none' | 'low' | 'medium' | 'high'; diff --git a/apps/mobile/src/state/composer-store-model.test.mjs b/apps/mobile/src/state/composer-store-model.test.mjs index 7618b16..8319594 100644 --- a/apps/mobile/src/state/composer-store-model.test.mjs +++ b/apps/mobile/src/state/composer-store-model.test.mjs @@ -91,6 +91,36 @@ test('enqueueComposerDraft consumes the current draft exactly once', () => { assert.deepEqual(first?.state.promptHistory.thread, ['preserve\nmultiline']); }); +test('queued prompts snapshot and restore the selected runtime model', () => { + const modelOverride = { + provider: 'openrouter', + model: 'anthropic/claude-sonnet-4', + reasoningEffort: 'high', + fast: false, + }; + const initial = { + ...EMPTY_COMPOSER_STATE, + drafts: { thread: 'Use the selected model' }, + }; + const enqueued = enqueueComposerDraft(initial, 'thread', 'model', 123, 'queue', modelOverride); + const restored = validStoredComposerState(enqueued?.state); + + assert.deepEqual(enqueued?.prompt.modelOverride, modelOverride); + assert.deepEqual(restored?.queues.thread?.[0]?.modelOverride, modelOverride); +}); + +test('stored queues reject malformed model overrides', () => { + const restored = validStoredComposerState({ + drafts: {}, + queues: { + thread: [{ ...prompt('bad-model'), modelOverride: { provider: '', model: 'valid' } }], + }, + paused: {}, + }); + + assert.equal(restored?.queues.thread, undefined); +}); + test('attachment-only drafts queue safely and retain the requested delivery mode', () => { const attachment = { id: 'a'.repeat(32), diff --git a/apps/mobile/src/state/composer-store-model.ts b/apps/mobile/src/state/composer-store-model.ts index bc20569..c931df2 100644 --- a/apps/mobile/src/state/composer-store-model.ts +++ b/apps/mobile/src/state/composer-store-model.ts @@ -1,3 +1,5 @@ +import type { ChatModelOverride } from './chat-thread-model.ts'; + export type QueuedPromptState = 'pending' | 'sending' | 'failed'; export type PromptDeliveryMode = 'queue' | 'redirect'; @@ -15,6 +17,8 @@ export type QueuedPrompt = { text: string; attachments: ComposerAttachment[]; deliveryMode: PromptDeliveryMode; + /** Runtime choice captured when the prompt was queued. */ + modelOverride?: ChatModelOverride; createdAt: number; state: QueuedPromptState; attemptedAt?: number; @@ -104,6 +108,7 @@ export function enqueueComposerDraft( id: string, createdAt: number, deliveryMode: PromptDeliveryMode = 'queue', + modelOverride?: ChatModelOverride, ): EnqueuedComposerDraft | null { const text = state.drafts[threadKey] ?? ''; const attachments = state.attachments[threadKey] ?? []; @@ -113,6 +118,7 @@ export function enqueueComposerDraft( text, attachments, deliveryMode, + ...(modelOverride ? { modelOverride } : {}), createdAt, state: 'pending', }; @@ -303,6 +309,7 @@ function storedQueuedPrompt(value: unknown): QueuedPrompt | null { const candidate = value as Partial; const attachments = candidate.attachments ?? []; const deliveryMode = candidate.deliveryMode ?? 'queue'; + const modelOverride = storedModelOverride(candidate.modelOverride); if (!( typeof candidate.id === 'string' && candidate.id.length > 0 && @@ -312,6 +319,7 @@ function storedQueuedPrompt(value: unknown): QueuedPrompt | null { attachments.every(isComposerAttachment) && (candidate.text.trim().length > 0 || attachments.length > 0) && isPromptDeliveryMode(deliveryMode) && + (candidate.modelOverride === undefined || modelOverride !== null) && typeof candidate.createdAt === 'number' && Number.isFinite(candidate.createdAt) && isQueuedPromptState(candidate.state) && @@ -319,7 +327,39 @@ function storedQueuedPrompt(value: unknown): QueuedPrompt | null { (typeof candidate.attemptedAt === 'number' && Number.isFinite(candidate.attemptedAt))) && (candidate.error === undefined || typeof candidate.error === 'string') )) return null; - return { ...candidate, attachments, deliveryMode } as QueuedPrompt; + return { + ...candidate, + attachments, + deliveryMode, + ...(modelOverride ? { modelOverride } : {}), + } as QueuedPrompt; +} + +function storedModelOverride(value: unknown): ChatModelOverride | null { + if (value === undefined) return null; + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const candidate = value as Partial; + if ( + typeof candidate.provider !== 'string' || + !candidate.provider.trim() || + candidate.provider.length > 256 || + typeof candidate.model !== 'string' || + !candidate.model.trim() || + candidate.model.length > 256 || + (candidate.reasoningEffort !== undefined && + (typeof candidate.reasoningEffort !== 'string' || + !candidate.reasoningEffort.trim() || + candidate.reasoningEffort.length > 64)) || + (candidate.fast !== undefined && typeof candidate.fast !== 'boolean') + ) { + return null; + } + return { + provider: candidate.provider, + model: candidate.model, + ...(candidate.reasoningEffort ? { reasoningEffort: candidate.reasoningEffort } : {}), + ...(typeof candidate.fast === 'boolean' ? { fast: candidate.fast } : {}), + }; } function normalizeQueuedPrompt(prompt: QueuedPrompt): QueuedPrompt { diff --git a/apps/mobile/src/state/composer-store.ts b/apps/mobile/src/state/composer-store.ts index ef8a2c5..fa46686 100644 --- a/apps/mobile/src/state/composer-store.ts +++ b/apps/mobile/src/state/composer-store.ts @@ -18,6 +18,7 @@ import { type PromptDeliveryMode, type StoredComposerState, } from '@/state/composer-store-model'; +import type { ChatModelOverride } from '@/state/chat-thread-model'; const STORAGE_KEY = 'brio.composer.v1'; const DRAFT_SAVE_DELAY_MS = 250; @@ -32,7 +33,11 @@ type ComposerState = StoredComposerState & { ensureSessionId: (threadKey: string, proposedId: string) => Promise; addAttachment: (threadKey: string, attachment: ComposerAttachment) => Promise; removeAttachment: (threadKey: string, attachmentId: string) => Promise; - enqueueDraft: (threadKey: string, deliveryMode?: PromptDeliveryMode) => Promise; + enqueueDraft: ( + threadKey: string, + deliveryMode?: PromptDeliveryMode, + modelOverride?: ChatModelOverride, + ) => Promise; undoDraft: (threadKey: string) => void; redoDraft: (threadKey: string) => void; claimNext: (threadKey: string) => Promise; @@ -175,13 +180,14 @@ export const useComposerStore = create((set, get) => { set(storedSlice(next)); return save(next); }, - enqueueDraft: async (threadKey, deliveryMode = 'queue') => { + enqueueDraft: async (threadKey, deliveryMode = 'queue', modelOverride) => { const enqueued = enqueueComposerDraft( storedSlice(get()), threadKey, promptId(), Date.now(), deliveryMode, + modelOverride, ); if (!enqueued) return null; set(storedSlice(enqueued.state));