pixelon 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,17 @@ export declare function usePlaceSuggestions(query: string, enabled: boolean): {
11
11
  } | null>;
12
12
  clear: () => void;
13
13
  };
14
+ /** SPEC-131 T6 — la dirección formateada de Google repite tramos.
15
+ *
16
+ * Medido: el `formattedAddress` de una calle de El Poblado vuelve como
17
+ * «Cl 10 #43-20, El Poblado, Medellín, El Poblado, Medellín, Antioquia», porque el nombre del
18
+ * lugar ya trae el barrio y la ciudad que luego añade la parte administrativa. Esa cadena se
19
+ * guarda tal cual y aparece en el chat, en la ficha, en la tabla y en Conversaciones.
20
+ *
21
+ * Se comparan los tramos sin tildes ni mayúsculas, que es donde estaba el otro caso visto
22
+ * («Itagüí, Itagüi»), y se conserva el primero de cada uno: el orden de una dirección va de lo
23
+ * particular a lo general, así que el que sobra siempre es el segundo. */
24
+ export declare function limpiarDireccion(address: string): string;
14
25
  /**
15
26
  * Traduce una coordenada a una dirección legible.
16
27
  * Requiere Geocoding habilitado en la clave del navegador; si no lo está, devuelve null y el
@@ -0,0 +1,8 @@
1
+ import type { ChatEventBus } from '../../../../../../core/eventBus/chatEventBus';
2
+ import type { RowActionContexto } from './types';
3
+ export interface ContextoDeLaAccionProps {
4
+ contexto: RowActionContexto;
5
+ row: Record<string, unknown>;
6
+ eventBus?: ChatEventBus;
7
+ }
8
+ export declare function ContextoDeLaAccion({ contexto, row, eventBus }: ContextoDeLaAccionProps): import("react/jsx-runtime").JSX.Element | null;
@@ -140,10 +140,37 @@ export interface RowActionDef {
140
140
  /** SPEC-002: varias llamadas en orden (ej. inspección = register-inspection foto + video). Si
141
141
  * se define, tiene prioridad sobre `endpoint`; todos comparten el mismo contexto del form. */
142
142
  endpoints?: EndpointRef[];
143
- /** Sub-form declarativo para acciones con input (asignar→grúa, completar→precio). SPEC-001 T2. */
143
+ /** Sub-form declarativo para acciones con input (asignar→grúa, completar→precio). SPEC-001 T2.
144
+ *
145
+ * SPEC-132 T5 — `contexto` es lo que se está decidiendo, en solo lectura y arriba del todo.
146
+ * Nació del drawer de «Confirmar pago»: pedía el medio y la referencia sin enseñar **ni el
147
+ * importe ni el comprobante** que el cliente acababa de subir. Para ver lo que estaba dando por
148
+ * bueno, la operadora tenía que cancelar, pulsar «Ver», mirar y volver a empezar. Confirmar un
149
+ * ingreso de dinero sin el dinero delante es firmar a ciegas. */
144
150
  form?: {
145
151
  fields: ReactiveCrudField[];
146
152
  payloadMap?: Record<string, string>;
153
+ contexto?: RowActionContexto;
154
+ };
155
+ }
156
+ /** SPEC-132 T5 — lo que hay que tener delante para decidir. */
157
+ export interface RowActionContexto {
158
+ /** Campos de la FILA (no del formulario) que se enseñan como pares etiqueta/valor. */
159
+ campos?: Array<{
160
+ field: string;
161
+ label: string;
162
+ moleculeType?: string;
163
+ }>;
164
+ /** El archivo que se está dando por bueno: se resuelve su URL y se enseña. */
165
+ adjunto?: {
166
+ /** Campo de la fila con el id del documento. */
167
+ idField: string;
168
+ label?: string;
169
+ /** Endpoint que traduce ese id a una URL mirable. Igual que en la vista de detalle. */
170
+ media: {
171
+ service: string;
172
+ urlPath: string;
173
+ };
147
174
  };
148
175
  }
149
176
  /** Vista de detalle ("Ver"): campos read-only + galería de archivos (fotos/videos/docs). */
@@ -15,6 +15,12 @@ export interface ServicesMapConfig {
15
15
  data: {
16
16
  list: EndpointRef;
17
17
  };
18
+ /** SPEC-131 T6 — qué pide el mapa cuando nadie acota nada.
19
+ *
20
+ * Un mapa de seguimiento sin filtro devuelve el historial entero, y eso es lo que recibía quien
21
+ * preguntaba «¿ya viene mi grúa?». El `filter` de la instancia PISA esto: cuando quien emite el
22
+ * componente sí sabe qué acotar, manda él. */
23
+ defaultFilter?: Record<string, unknown>;
18
24
  mapping: ServicesMapMapping;
19
25
  /** Icono para todos los puntos cuando la fila no trae uno. */
20
26
  defaultIcon?: string;
@@ -14,9 +14,16 @@ export interface UseAutoScrollOptions {
14
14
  * desconectarlo), dos envíos seguidos dejaban dos observadores compitiendo por mover el scroll,
15
15
  * y ninguno se limpiaba al desmontar.
16
16
  */
17
+ /** SPEC-131 T3 — cuánto espacio hay que reservar al final para que el objetivo pueda subir arriba.
18
+ *
19
+ * Lo que falte y nada más: si desde el objetivo hasta el final ya hay una pantalla de contenido,
20
+ * no hace falta reservar nada. Reservar `clientHeight` a ciegas mete una pantalla de blanco al
21
+ * final, y con el scroll topado eso deja la conversación entera por encima del borde superior. */
22
+ export declare function reservaNecesaria(clientHeight: number, scrollHeight: number, targetTop: number): number;
17
23
  export declare const useAutoScroll: ({ messagesEndRef, messagesContainerRef, }: UseAutoScrollOptions) => {
18
24
  scrollToLastUserMessage: () => void;
19
25
  pinLastUserMessage: () => void;
20
26
  scrollToProactive: () => void;
21
- scrollToBottom: () => void;
27
+ scrollToBottom: (suave?: boolean) => void;
28
+ aterrizarAlFinal: () => void;
22
29
  };
@@ -0,0 +1,13 @@
1
+ export interface LineaResumen {
2
+ label: string;
3
+ valor: string;
4
+ }
5
+ interface CampoResumible {
6
+ name: string;
7
+ label?: string;
8
+ moleculeType?: string;
9
+ uiProps?: Record<string, unknown>;
10
+ }
11
+ /** Lo que se va a enviar, en el orden del formulario y sin los campos vacíos. */
12
+ export declare function resumenParaConfirmar(campos: ReadonlyArray<CampoResumible>, datos: Record<string, unknown> | null | undefined): LineaResumen[];
13
+ export {};
@@ -51,6 +51,12 @@ export interface RenderFieldConfig {
51
51
  formatValue?: (value: unknown, row: unknown) => React.ReactNode;
52
52
  /** Función para parsear el valor antes de submit */
53
53
  parseValue?: (value: unknown) => unknown;
54
+ /** SPEC-131 T7 — cuántas columnas ocupa el campo en el formulario.
55
+ *
56
+ * En un grid las celdas de una fila comparten altura, así que un campo que se despliega —el de
57
+ * ubicación, que abre un mapa de verificación al elegir la dirección— deja a su hermana con
58
+ * cientos de píxeles de blanco al lado. Los campos que crecen ocupan la fila entera. */
59
+ gridSpan?: number;
54
60
  }
55
61
  /**
56
62
  * Estado de paginación
package/dist/index.esm.js CHANGED
@@ -10924,6 +10924,7 @@ const Drawer = ({
10924
10924
  "data-testid": dataTestId,
10925
10925
  scrollIndicatorOffset = 1
10926
10926
  }) => {
10927
+ const idTitulo = useId();
10927
10928
  const [isMobile, setIsMobile] = useState(false);
10928
10929
  const contentRef = useRef(null);
10929
10930
  const [showScrollIndicator, setShowScrollIndicator] = useState(false);
@@ -11047,14 +11048,14 @@ const Drawer = ({
11047
11048
  style: getDrawerStyles(),
11048
11049
  role: "dialog",
11049
11050
  "aria-modal": "true",
11050
- "aria-labelledby": title ? "drawer-title" : void 0,
11051
+ "aria-labelledby": title ? idTitulo : void 0,
11051
11052
  "data-testid": dataTestId,
11052
11053
  children: /* @__PURE__ */ jsxs("div", { className: `${COMPONENT_PREFIX}drawer__viewport`, children: [
11053
11054
  (title || showCloseButton || headerExtra) && /* @__PURE__ */ jsxs("div", { className: `${COMPONENT_PREFIX}drawer__header`, children: [
11054
11055
  title && /* @__PURE__ */ jsx(
11055
11056
  "h2",
11056
11057
  {
11057
- id: "drawer-title",
11058
+ id: idTitulo,
11058
11059
  className: `${COMPONENT_PREFIX}drawer__title`,
11059
11060
  children: title
11060
11061
  }
@@ -13167,7 +13168,9 @@ function mapFieldsToFormFields(fields, mode) {
13167
13168
  readOnly: field.readOnly,
13168
13169
  placeholder: field.placeholder,
13169
13170
  uiProps: field.uiProps,
13170
- transformOnSubmit: field.parseValue
13171
+ transformOnSubmit: field.parseValue,
13172
+ gridSpan: field.gridSpan
13173
+ // SPEC-131 T7
13171
13174
  }));
13172
13175
  }
13173
13176
 
@@ -13347,6 +13350,47 @@ function calculateTotalPages(totalRecords, limit) {
13347
13350
  return Math.ceil(totalRecords / limit);
13348
13351
  }
13349
13352
 
13353
+ function etiquetaDeOpcion(valor, uiProps) {
13354
+ const opciones = uiProps?.options;
13355
+ if (!Array.isArray(opciones)) return null;
13356
+ const encontrada = opciones.find((o) => o && String(o.value) === String(valor));
13357
+ return encontrada?.label != null ? String(encontrada.label) : null;
13358
+ }
13359
+ function esNumerico(moleculeType) {
13360
+ return moleculeType === "INPUT_CURRENCY" || moleculeType === "INPUT_NUMBER";
13361
+ }
13362
+ function aTexto$1(valor, uiProps, moleculeType) {
13363
+ if (valor == null || valor === "") return "";
13364
+ if (typeof valor === "boolean") return valor ? "S\xED" : "No";
13365
+ if (esNumerico(moleculeType)) {
13366
+ const n = Number(String(valor).replace(/[^\d.-]/g, ""));
13367
+ if (Number.isFinite(n)) return n.toLocaleString();
13368
+ }
13369
+ if (typeof File !== "undefined" && valor instanceof File) return valor.name;
13370
+ if (Array.isArray(valor)) {
13371
+ const partes = valor.map((v) => aTexto$1(v, uiProps, moleculeType)).filter(Boolean);
13372
+ return partes.join(", ");
13373
+ }
13374
+ if (typeof valor === "object") {
13375
+ const o = valor;
13376
+ for (const clave of ["label", "address", "name", "text", "value"]) {
13377
+ if (typeof o[clave] === "string" && o[clave]) return o[clave];
13378
+ }
13379
+ return "";
13380
+ }
13381
+ return etiquetaDeOpcion(valor, uiProps) ?? String(valor);
13382
+ }
13383
+ function resumenParaConfirmar(campos, datos) {
13384
+ if (!datos) return [];
13385
+ const lineas = [];
13386
+ for (const campo of campos) {
13387
+ const valor = aTexto$1(datos[campo.name], campo.uiProps, campo.moleculeType);
13388
+ if (!valor) continue;
13389
+ lineas.push({ label: campo.label ?? campo.name, valor });
13390
+ }
13391
+ return lineas;
13392
+ }
13393
+
13350
13394
  const useAnchoPropio = () => {
13351
13395
  const ref = useRef(null);
13352
13396
  const [ancho, setAncho] = useState(null);
@@ -13791,6 +13835,10 @@ function Crud({
13791
13835
  return "Formulario";
13792
13836
  }
13793
13837
  };
13838
+ const resumenConfirmacion = useMemo(
13839
+ () => confirmAction === "create" || confirmAction === "edit" ? resumenParaConfirmar(formFields, pendingFormData) : [],
13840
+ [confirmAction, formFields, pendingFormData]
13841
+ );
13794
13842
  const getConfirmMessage = () => {
13795
13843
  switch (confirmAction) {
13796
13844
  case "create":
@@ -13930,6 +13978,10 @@ function Crud({
13930
13978
  className: `${COMPONENT_PREFIX}crud__confirm-drawer`,
13931
13979
  children: /* @__PURE__ */ jsxs("div", { className: `${COMPONENT_PREFIX}crud__confirm-content`, children: [
13932
13980
  /* @__PURE__ */ jsx("p", { className: `${COMPONENT_PREFIX}crud__confirm-message`, children: getConfirmMessage() }),
13981
+ resumenConfirmacion.length > 0 && /* @__PURE__ */ jsx("dl", { className: `${COMPONENT_PREFIX}crud__confirm-summary`, children: resumenConfirmacion.map((linea) => /* @__PURE__ */ jsxs("div", { className: `${COMPONENT_PREFIX}crud__confirm-summary-row`, children: [
13982
+ /* @__PURE__ */ jsx("dt", { children: linea.label }),
13983
+ /* @__PURE__ */ jsx("dd", { children: linea.valor })
13984
+ ] }, linea.label)) }),
13933
13985
  /* @__PURE__ */ jsxs("div", { className: `${COMPONENT_PREFIX}crud__confirm-actions`, children: [
13934
13986
  /* @__PURE__ */ jsx(
13935
13987
  Button,
@@ -14104,7 +14156,7 @@ function getFormatter(key) {
14104
14156
  }
14105
14157
 
14106
14158
  const OPTIONS_LOAD_ERROR = "No se pudieron cargar las opciones. Int\xE9ntalo de nuevo.";
14107
- function unwrap$4(raw) {
14159
+ function unwrap$5(raw) {
14108
14160
  if (raw && typeof raw === "object" && "response" in raw) {
14109
14161
  return raw.response;
14110
14162
  }
@@ -14125,7 +14177,7 @@ function resolveBody(body, ctx) {
14125
14177
  }
14126
14178
  async function loadOptions(eventBus, ep, ctx) {
14127
14179
  const raw = await fetchViaEventBus(eventBus, ep.service, ep.url, ep.method, resolveBody(ep.body, ctx));
14128
- let data = unwrap$4(raw);
14180
+ let data = unwrap$5(raw);
14129
14181
  if (ep.response_path && data && typeof data === "object" && !Array.isArray(data)) {
14130
14182
  data = data[ep.response_path];
14131
14183
  }
@@ -14163,7 +14215,7 @@ const mensajeDeError = (e, respaldo) => {
14163
14215
  };
14164
14216
 
14165
14217
  const REFRESH_INTERVAL_MS = 2e4;
14166
- function unwrap$3(raw) {
14218
+ function unwrap$4(raw) {
14167
14219
  if (raw && typeof raw === "object" && "response" in raw) {
14168
14220
  return raw.response;
14169
14221
  }
@@ -14212,7 +14264,7 @@ function useReactiveCrud(config, eventBus) {
14212
14264
  filters
14213
14265
  });
14214
14266
  assertOk(raw);
14215
- const data = unwrap$3(raw);
14267
+ const data = unwrap$4(raw);
14216
14268
  let rows = [];
14217
14269
  let total = 0;
14218
14270
  if (Array.isArray(data)) {
@@ -14416,6 +14468,72 @@ const FileField = ({
14416
14468
  ] });
14417
14469
  };
14418
14470
 
14471
+ function unwrap$3(raw) {
14472
+ if (raw && typeof raw === "object" && "response" in raw) {
14473
+ return raw.response;
14474
+ }
14475
+ return raw;
14476
+ }
14477
+ function aTexto(valor, locale, moleculeType) {
14478
+ if (valor == null || valor === "") return "";
14479
+ if (moleculeType === "INPUT_CURRENCY" || moleculeType === "INPUT_NUMBER") {
14480
+ const n = Number(valor);
14481
+ if (!Number.isNaN(n)) return n.toLocaleString(locale || "es-CO");
14482
+ }
14483
+ if (typeof valor === "object") {
14484
+ const o = valor;
14485
+ for (const clave of ["label", "address", "name", "text", "value"]) {
14486
+ if (typeof o[clave] === "string" && o[clave]) return o[clave];
14487
+ }
14488
+ return "";
14489
+ }
14490
+ return String(valor);
14491
+ }
14492
+ function ContextoDeLaAccion({ contexto, row, eventBus }) {
14493
+ const { locale } = useChatConfig();
14494
+ const [urlAdjunto, setUrlAdjunto] = useState("");
14495
+ const [estadoAdjunto, setEstadoAdjunto] = useState("idle");
14496
+ const adjunto = contexto.adjunto;
14497
+ const idAdjunto = adjunto ? String(row[adjunto.idField] ?? "") : "";
14498
+ useEffect(() => {
14499
+ if (!adjunto || !idAdjunto || !eventBus) return;
14500
+ let vivo = true;
14501
+ setEstadoAdjunto("cargando");
14502
+ void (async () => {
14503
+ try {
14504
+ const crudo = unwrap$3(
14505
+ await fetchViaEventBus(eventBus, adjunto.media.service, adjunto.media.urlPath.replace("{id}", idAdjunto), "GET")
14506
+ );
14507
+ const url = typeof crudo === "string" ? crudo : String(crudo?.url ?? "");
14508
+ if (!vivo) return;
14509
+ setUrlAdjunto(url);
14510
+ setEstadoAdjunto(url ? "idle" : "error");
14511
+ } catch {
14512
+ if (vivo) setEstadoAdjunto("error");
14513
+ }
14514
+ })();
14515
+ return () => {
14516
+ vivo = false;
14517
+ };
14518
+ }, [adjunto, idAdjunto, eventBus]);
14519
+ const lineas = (contexto.campos ?? []).map((c) => ({ label: c.label, valor: aTexto(row[c.field], locale, c.moleculeType) })).filter((l) => l.valor);
14520
+ if (lineas.length === 0 && !adjunto) return null;
14521
+ return /* @__PURE__ */ jsxs("div", { className: "px-chat-reactive-crud__contexto", children: [
14522
+ lineas.length > 0 && /* @__PURE__ */ jsx("dl", { className: "px-chat-reactive-crud__contexto-datos", children: lineas.map((l) => /* @__PURE__ */ jsxs("div", { className: "px-chat-reactive-crud__contexto-linea", children: [
14523
+ /* @__PURE__ */ jsx("dt", { children: l.label }),
14524
+ /* @__PURE__ */ jsx("dd", { children: l.valor })
14525
+ ] }, l.label)) }),
14526
+ adjunto && /* @__PURE__ */ jsxs("div", { className: "px-chat-reactive-crud__contexto-adjunto", children: [
14527
+ adjunto.label && /* @__PURE__ */ jsx("span", { className: "px-chat-reactive-crud__contexto-adjunto-label", children: adjunto.label }),
14528
+ estadoAdjunto === "cargando" && /* @__PURE__ */ jsx("span", { className: "px-chat-reactive-crud__contexto-nota", children: "Cargando\u2026" }),
14529
+ estadoAdjunto === "error" && /* @__PURE__ */ jsx("span", { role: "alert", className: "px-chat-reactive-crud__contexto-nota", children: "No se pudo cargar el archivo. M\xEDralo en \xABVer\xBB antes de confirmar." }),
14530
+ urlAdjunto && estadoAdjunto === "idle" && // Se abre aparte porque el comprobante de una transferencia puede ser un PDF o una
14531
+ // captura larga, y aquí solo cabe un vistazo.
14532
+ /* @__PURE__ */ jsx("a", { href: urlAdjunto, target: "_blank", rel: "noopener noreferrer", children: /* @__PURE__ */ jsx("img", { src: urlAdjunto, alt: adjunto.label ?? "Archivo adjunto", className: "px-chat-reactive-crud__contexto-imagen" }) })
14533
+ ] })
14534
+ ] });
14535
+ }
14536
+
14419
14537
  const RESPALDO = { miles: ".", decimal: "," };
14420
14538
  function separadoresDelLocale(locale) {
14421
14539
  if (!locale) return RESPALDO;
@@ -14571,6 +14689,7 @@ function ActionFormDrawer({ action, row, idField, eventBus, onExecute, onClose }
14571
14689
  };
14572
14690
  const uploading = Object.values(fileEntries).some((list) => list.some((e) => e.status === "uploading"));
14573
14691
  return /* @__PURE__ */ jsx(Drawer$1, { isOpen: true, onClose, title: action.label, placement: "right", children: loadingOptions ? /* @__PURE__ */ jsx("div", { className: "px-chat-reactive-crud__action-loading", children: "Cargando\u2026" }) : /* @__PURE__ */ jsxs("div", { className: "px-chat-reactive-crud__action-form", children: [
14692
+ action.form?.contexto && /* @__PURE__ */ jsx(ContextoDeLaAccion, { contexto: action.form.contexto, row, eventBus }),
14574
14693
  fileFields.map((f) => /* @__PURE__ */ jsx(
14575
14694
  FileField,
14576
14695
  {
@@ -15875,7 +15994,8 @@ function usePlaceSuggestions(query, enabled) {
15875
15994
  return {
15876
15995
  lat: typeof loc.lat === "function" ? loc.lat() : loc.lat,
15877
15996
  lng: typeof loc.lng === "function" ? loc.lng() : loc.lng,
15878
- address: String(place.formattedAddress ?? "")
15997
+ address: limpiarDireccion(String(place.formattedAddress ?? ""))
15998
+ // SPEC-131 T6
15879
15999
  };
15880
16000
  } catch {
15881
16001
  return null;
@@ -15885,13 +16005,27 @@ function usePlaceSuggestions(query, enabled) {
15885
16005
  );
15886
16006
  return { suggestions, resolve, clear: () => setSuggestions([]) };
15887
16007
  }
16008
+ function limpiarDireccion(address) {
16009
+ const normal = (s) => s.trim().toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
16010
+ const vistos = /* @__PURE__ */ new Set();
16011
+ const tramos = [];
16012
+ for (const tramo of address.split(",")) {
16013
+ const limpio = tramo.trim();
16014
+ if (!limpio) continue;
16015
+ const clave = normal(limpio);
16016
+ if (vistos.has(clave)) continue;
16017
+ vistos.add(clave);
16018
+ tramos.push(limpio);
16019
+ }
16020
+ return tramos.join(", ");
16021
+ }
15888
16022
  function reverseGeocode(lat, lng) {
15889
16023
  return new Promise((resolve) => {
15890
16024
  const maps = window.google?.maps;
15891
16025
  if (!maps?.Geocoder) return resolve(null);
15892
16026
  new maps.Geocoder().geocode({ location: { lat, lng } }, (results, status) => {
15893
16027
  if (status !== "OK" || !results?.length) return resolve(null);
15894
- resolve(String(results[0].formatted_address ?? "") || null);
16028
+ resolve(limpiarDireccion(String(results[0].formatted_address ?? "")) || null);
15895
16029
  });
15896
16030
  });
15897
16031
  }
@@ -16048,6 +16182,10 @@ function toRenderField(f, loaded, eventBus, mapsApiKey, loadError) {
16048
16182
  showInModes: f.showInModes,
16049
16183
  required: f.required,
16050
16184
  placeholder: f.placeholder,
16185
+ // SPEC-131 T7 — la fila entera: al elegir la dirección se despliega el mapa de verificación
16186
+ // y, a media fila, su campo hermano se quedaba con ~400px de blanco al lado. Medido entre
16187
+ // «¿En qué situación está?» y «¿A dónde lo llevamos?» del formulario de solicitud.
16188
+ gridSpan: 2,
16051
16189
  uiProps: {
16052
16190
  // SPEC-074 — `error` viene del render de CUSTOM (DynamicForm ya lo pasaba) y aquí se
16053
16191
  // desechaba: el campo obligatorio más importante era el único mudo.
@@ -16672,6 +16810,8 @@ const ServicesMap = ({ config, eventBus, apiKey, filter, fill }) => {
16672
16810
  const { service, url, method, payloadMap } = config.data.list;
16673
16811
  void fetchViaEventBus(eventBus, service, url, method ?? "POST", {
16674
16812
  ...payloadMap,
16813
+ // SPEC-131 T6 — el del config primero, el de la instancia después: quien acota manda.
16814
+ ...config.defaultFilter ?? {},
16675
16815
  ...filter ?? {}
16676
16816
  }).then((res) => {
16677
16817
  if (!alive) return;
@@ -18825,6 +18965,10 @@ const useInputFocus = ({
18825
18965
  };
18826
18966
  };
18827
18967
 
18968
+ function reservaNecesaria(clientHeight, scrollHeight, targetTop) {
18969
+ const debajoDelObjetivo = scrollHeight - targetTop;
18970
+ return Math.max(0, clientHeight - debajoDelObjetivo);
18971
+ }
18828
18972
  const useAutoScroll = ({
18829
18973
  messagesEndRef,
18830
18974
  messagesContainerRef
@@ -18843,11 +18987,12 @@ const useAutoScroll = ({
18843
18987
  const container = messagesContainerRef.current;
18844
18988
  const spacer = messagesEndRef.current;
18845
18989
  if (!container || !spacer || !target) return;
18846
- spacer.style.minHeight = `${container.clientHeight}px`;
18990
+ spacer.style.minHeight = "0px";
18847
18991
  const containerRect = container.getBoundingClientRect();
18848
18992
  const msgRect = target.getBoundingClientRect();
18849
18993
  const padTop = parseFloat(getComputedStyle(container).paddingTop) || 0;
18850
18994
  const targetTop = msgRect.top - containerRect.top + container.scrollTop - padTop;
18995
+ spacer.style.minHeight = `${reservaNecesaria(container.clientHeight, container.scrollHeight, targetTop)}px`;
18851
18996
  container.scrollTo({ top: targetTop, behavior: "smooth" });
18852
18997
  }, [messagesContainerRef, messagesEndRef]);
18853
18998
  const last = useCallback((selector) => {
@@ -18889,15 +19034,33 @@ const useAutoScroll = ({
18889
19034
  });
18890
19035
  }, 150);
18891
19036
  }, [doScroll]);
18892
- const scrollToBottom = useCallback(() => {
19037
+ const aterrizarAlFinal = useCallback(() => {
19038
+ const container = messagesContainerRef.current;
19039
+ if (!container) return;
19040
+ resetSpacer();
19041
+ const hasta = Date.now() + 1e3;
19042
+ let ultimoAlto = -1;
19043
+ const pegar = () => {
19044
+ const c = messagesContainerRef.current;
19045
+ if (!c) return;
19046
+ if (c.scrollHeight !== ultimoAlto) {
19047
+ ultimoAlto = c.scrollHeight;
19048
+ c.scrollTop = c.scrollHeight;
19049
+ }
19050
+ if (Date.now() < hasta) requestAnimationFrame(pegar);
19051
+ };
19052
+ requestAnimationFrame(pegar);
19053
+ }, [messagesContainerRef, resetSpacer]);
19054
+ const scrollToBottom = useCallback((suave = true) => {
18893
19055
  cancelPending();
18894
19056
  resetSpacer();
18895
19057
  requestAnimationFrame(() => {
19058
+ const comportamiento = suave ? "smooth" : "auto";
18896
19059
  const container = messagesContainerRef.current;
18897
19060
  if (container) {
18898
- container.scrollTo({ top: container.scrollHeight, behavior: "smooth" });
19061
+ container.scrollTo({ top: container.scrollHeight, behavior: comportamiento });
18899
19062
  } else {
18900
- messagesEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
19063
+ messagesEndRef.current?.scrollIntoView({ behavior: comportamiento, block: "end" });
18901
19064
  }
18902
19065
  });
18903
19066
  }, [cancelPending, resetSpacer, messagesContainerRef, messagesEndRef]);
@@ -18905,7 +19068,9 @@ const useAutoScroll = ({
18905
19068
  scrollToLastUserMessage,
18906
19069
  pinLastUserMessage,
18907
19070
  scrollToProactive,
18908
- scrollToBottom
19071
+ scrollToBottom,
19072
+ aterrizarAlFinal
19073
+ // SPEC-131 T3
18909
19074
  };
18910
19075
  };
18911
19076
 
@@ -19458,7 +19623,7 @@ const Chat = ({
19458
19623
  usePreventOverscroll();
19459
19624
  const { showThinking, scrollButtonState, scrollToTopPending } = useChatStore();
19460
19625
  const { attachments, handleAttachmentsChange, clearAttachments } = useAttachments();
19461
- const { scrollToBottom, scrollToLastUserMessage, pinLastUserMessage, scrollToProactive } = useAutoScroll({
19626
+ const { scrollToBottom, scrollToLastUserMessage, pinLastUserMessage, scrollToProactive, aterrizarAlFinal } = useAutoScroll({
19462
19627
  messagesEndRef,
19463
19628
  messagesContainerRef
19464
19629
  });
@@ -19486,11 +19651,23 @@ const Chat = ({
19486
19651
  }
19487
19652
  }, [scrollToTopPending, scrollToLastUserMessage]);
19488
19653
  useEffect(() => {
19654
+ if (messages[messages.length - 1]?.proactive) return;
19489
19655
  const spacer = messagesEndRef.current;
19490
19656
  if (spacer && spacer.style.minHeight && spacer.style.minHeight !== "0px") {
19491
19657
  pinLastUserMessage();
19492
19658
  }
19493
19659
  }, [messages]);
19660
+ const yaAterrizo = useRef(false);
19661
+ useEffect(() => {
19662
+ if (messages.length === 0) {
19663
+ yaAterrizo.current = false;
19664
+ return;
19665
+ }
19666
+ if (yaAterrizo.current) return;
19667
+ yaAterrizo.current = true;
19668
+ if (messages[messages.length - 1]?.proactive) return;
19669
+ aterrizarAlFinal();
19670
+ }, [messages, aterrizarAlFinal]);
19494
19671
  const lastProactiveIdRef = useRef(void 0);
19495
19672
  useEffect(() => {
19496
19673
  const last = messages[messages.length - 1];
@@ -19608,7 +19785,7 @@ const Chat = ({
19608
19785
  {
19609
19786
  className: `${COMPONENT_PREFIX$1}chat__scroll-to-bottom`,
19610
19787
  variant: "circle",
19611
- onClick: scrollToBottom,
19788
+ onClick: () => scrollToBottom(),
19612
19789
  "aria-label": "Ir al final",
19613
19790
  icon: /* @__PURE__ */ jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
19614
19791
  /* @__PURE__ */ jsx("path", { d: "M12 8v8", strokeWidth: "2", strokeLinecap: "round" }),