@opengeni/react 7.1.2 → 7.2.0-canary.36239117573001

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.
Files changed (34) hide show
  1. package/dist/{browser-viewer-TZ6T76E3.js → browser-viewer-GEYFZE2W.js} +2 -2
  2. package/dist/{chunk-CP6IFD2Z.js → chunk-6IUUN2AQ.js} +124 -58
  3. package/dist/chunk-6IUUN2AQ.js.map +1 -0
  4. package/dist/{chunk-VHEY4CB3.js → chunk-RSFCYKA7.js} +5 -3
  5. package/dist/{chunk-VHEY4CB3.js.map → chunk-RSFCYKA7.js.map} +1 -1
  6. package/dist/{chunk-VI7HELBI.js → chunk-SJ5HE4ZA.js} +21 -2
  7. package/dist/chunk-SJ5HE4ZA.js.map +1 -0
  8. package/dist/{chunk-4ACTWOOW.js → chunk-VDSZ3AVO.js} +41 -18
  9. package/dist/chunk-VDSZ3AVO.js.map +1 -0
  10. package/dist/{chunk-2O2HXRGP.js → chunk-Y6DMQTLA.js} +130 -58
  11. package/dist/chunk-Y6DMQTLA.js.map +1 -0
  12. package/dist/composer.js +1 -1
  13. package/dist/{computer-viewer-FX4WOZMX.js → computer-viewer-BNHZ4PYO.js} +2 -2
  14. package/dist/index.js +5 -5
  15. package/dist/interaction.js +2 -2
  16. package/dist/session-ui.js +3 -3
  17. package/dist/session.js +1 -1
  18. package/dist/timeline/types.d.ts +2 -0
  19. package/package.json +5 -5
  20. package/src/components/browser-viewer.tsx +146 -57
  21. package/src/components/composer-transcription-control.tsx +4 -2
  22. package/src/components/composer.tsx +45 -20
  23. package/src/components/computer-viewer.tsx +161 -57
  24. package/src/hooks/use-browser-frame-stream.ts +3 -1
  25. package/src/hooks/use-voice-input.ts +12 -1
  26. package/src/timeline/compaction-copy.ts +2 -0
  27. package/src/timeline/projection.ts +31 -5
  28. package/src/timeline/types.ts +2 -0
  29. package/dist/chunk-2O2HXRGP.js.map +0 -1
  30. package/dist/chunk-4ACTWOOW.js.map +0 -1
  31. package/dist/chunk-CP6IFD2Z.js.map +0 -1
  32. package/dist/chunk-VI7HELBI.js.map +0 -1
  33. /package/dist/{browser-viewer-TZ6T76E3.js.map → browser-viewer-GEYFZE2W.js.map} +0 -0
  34. /package/dist/{computer-viewer-FX4WOZMX.js.map → computer-viewer-BNHZ4PYO.js.map} +0 -0
@@ -50,6 +50,7 @@ import {
50
50
  useCallback,
51
51
  useEffect,
52
52
  useId,
53
+ useLayoutEffect,
53
54
  useMemo,
54
55
  useRef,
55
56
  useState,
@@ -236,6 +237,7 @@ export function BrowserViewer({
236
237
  const previousSessionIdRef = useRef(sessionId);
237
238
  const seenInterventionIdsRef = useRef(new Set<string>());
238
239
  const diagnosticRequestRef = useRef(0);
240
+ const viewportFocusHandoffRef = useRef<string | null>(null);
239
241
  const [diagnosticsView, setDiagnosticsView] = useState<BrowserDiagnosticsView | null>(null);
240
242
 
241
243
  const notifyError = useCallback(
@@ -342,12 +344,8 @@ export function BrowserViewer({
342
344
  setResumeAttempt((visible) =>
343
345
  visible?.sessionId === selectedRegistrySession.id ? null : visible,
344
346
  );
345
- return;
346
- }
347
- if (selectedRegistrySession.lifecycle === "suspended") {
348
- void wakeBrowser(selectedRegistrySession);
349
347
  }
350
- }, [selectedRegistrySession, wakeBrowser]);
348
+ }, [selectedRegistrySession]);
351
349
 
352
350
  const browser = useBrowserSession({
353
351
  ...override,
@@ -883,6 +881,20 @@ export function BrowserViewer({
883
881
  }}
884
882
  />
885
883
  <BrowserViewport
884
+ // A new control scope must discard every buffered gesture and queued action.
885
+ key={JSON.stringify([
886
+ selection?.sessionId,
887
+ browser.selectedTarget?.id,
888
+ browser.selectedTarget?.targetGeneration,
889
+ browser.selectedTarget?.documentGeneration,
890
+ frames.attachment?.controllerGeneration,
891
+ ])}
892
+ focusHandoffRef={viewportFocusHandoffRef}
893
+ focusScope={JSON.stringify([
894
+ selection?.sessionId,
895
+ browser.selectedTarget?.id,
896
+ browser.selectedTarget?.targetGeneration,
897
+ ])}
886
898
  frame={displayedFrame}
887
899
  connectionState={displayConnectionState}
888
900
  supportsLiveFrames={supportsLiveFrames}
@@ -1828,6 +1840,8 @@ function BrowserAddressBar(props: {
1828
1840
  }
1829
1841
 
1830
1842
  function BrowserViewport(props: {
1843
+ focusHandoffRef: { current: string | null };
1844
+ focusScope: string;
1831
1845
  frame: BrowserFrame | null;
1832
1846
  connectionState: string;
1833
1847
  supportsLiveFrames: boolean;
@@ -1872,12 +1886,26 @@ function BrowserViewport(props: {
1872
1886
  const actionTailRef = useRef<Promise<void>>(Promise.resolve());
1873
1887
  const actionQueueEpochRef = useRef(0);
1874
1888
  const queuedFrameRef = useRef<BrowserFrame | null>(null);
1889
+ const currentFrameRef = useRef<BrowserFrame | null>(props.frame);
1890
+ const paintedFrameRef = useRef<BrowserFrame | null>(null);
1891
+ const [paintedFrame, setPaintedFrame] = useState<BrowserFrame | null>(null);
1875
1892
  const decodingFrameRef = useRef(false);
1876
1893
  const mountedRef = useRef(true);
1877
1894
  actionRef.current = props.onAction;
1878
1895
  readClipboardRef.current = props.onReadClipboard;
1879
1896
  errorRef.current = props.onError;
1880
1897
 
1898
+ const clearBufferedInput = useCallback(() => {
1899
+ if (wheelRef.current?.timer) clearTimeout(wheelRef.current.timer);
1900
+ if (pendingTextRef.current?.timer) clearTimeout(pendingTextRef.current.timer);
1901
+ wheelRef.current = null;
1902
+ pendingTextRef.current = null;
1903
+ pointerStartRef.current = null;
1904
+ lastClickRef.current = null;
1905
+ composingRef.current = false;
1906
+ if (inputRef.current) inputRef.current.value = "";
1907
+ }, []);
1908
+
1881
1909
  const paintQueuedFrames = useCallback(() => {
1882
1910
  if (decodingFrameRef.current) return;
1883
1911
  decodingFrameRef.current = true;
@@ -1897,8 +1925,15 @@ function BrowserViewport(props: {
1897
1925
  const bitmap = await createImageBitmap(blob);
1898
1926
  try {
1899
1927
  const canvas = canvasRef.current;
1900
- if (mountedRef.current && canvas) {
1928
+ if (
1929
+ mountedRef.current &&
1930
+ canvas &&
1931
+ currentFrameRef.current &&
1932
+ sameBrowserDocument(frame, currentFrameRef.current)
1933
+ ) {
1901
1934
  paintCanvas(canvas, bitmap, frame.width, frame.height);
1935
+ paintedFrameRef.current = frame;
1936
+ setPaintedFrame(frame);
1902
1937
  }
1903
1938
  } finally {
1904
1939
  bitmap.close();
@@ -1908,11 +1943,23 @@ function BrowserViewport(props: {
1908
1943
  objectUrl = URL.createObjectURL(blob);
1909
1944
  const image = await loadImage(objectUrl);
1910
1945
  const canvas = canvasRef.current;
1911
- if (mountedRef.current && canvas) {
1946
+ if (
1947
+ mountedRef.current &&
1948
+ canvas &&
1949
+ currentFrameRef.current &&
1950
+ sameBrowserDocument(frame, currentFrameRef.current)
1951
+ ) {
1912
1952
  paintCanvas(canvas, image, frame.width, frame.height);
1953
+ paintedFrameRef.current = frame;
1954
+ setPaintedFrame(frame);
1913
1955
  }
1914
1956
  } catch (cause) {
1915
- if (mountedRef.current) errorRef.current(cause);
1957
+ if (
1958
+ mountedRef.current &&
1959
+ currentFrameRef.current &&
1960
+ sameBrowserDocument(frame, currentFrameRef.current)
1961
+ )
1962
+ errorRef.current(cause);
1916
1963
  } finally {
1917
1964
  if (objectUrl) URL.revokeObjectURL(objectUrl);
1918
1965
  }
@@ -1924,27 +1971,41 @@ function BrowserViewport(props: {
1924
1971
  })();
1925
1972
  }, []);
1926
1973
 
1927
- useEffect(() => {
1974
+ useLayoutEffect(() => {
1975
+ const keyboardInput = inputRef.current;
1976
+ const focusHandoff = props.focusHandoffRef;
1928
1977
  mountedRef.current = true;
1978
+ if (focusHandoff.current === props.focusScope) {
1979
+ keyboardInput?.focus({ preventScroll: true });
1980
+ }
1981
+ focusHandoff.current = null;
1929
1982
  return () => {
1983
+ // Only a same-target replacement may inherit focus the keyboard sink owned.
1984
+ if (document.activeElement === keyboardInput) {
1985
+ focusHandoff.current = props.focusScope;
1986
+ }
1930
1987
  mountedRef.current = false;
1988
+ actionQueueEpochRef.current += 1;
1931
1989
  queuedFrameRef.current = null;
1990
+ clearBufferedInput();
1932
1991
  };
1933
- }, []);
1992
+ }, [clearBufferedInput, props.focusHandoffRef, props.focusScope]);
1934
1993
 
1935
- useEffect(() => {
1936
- if (!props.frame) return;
1994
+ useLayoutEffect(() => {
1995
+ if (currentFrameRef.current && !props.frame) {
1996
+ actionQueueEpochRef.current += 1;
1997
+ clearBufferedInput();
1998
+ }
1999
+ currentFrameRef.current = props.frame;
2000
+ if (!props.frame) {
2001
+ queuedFrameRef.current = null;
2002
+ paintedFrameRef.current = null;
2003
+ setPaintedFrame(null);
2004
+ return;
2005
+ }
1937
2006
  queuedFrameRef.current = props.frame;
1938
2007
  paintQueuedFrames();
1939
- }, [paintQueuedFrames, props.frame]);
1940
-
1941
- useEffect(
1942
- () => () => {
1943
- if (wheelRef.current?.timer) clearTimeout(wheelRef.current.timer);
1944
- if (pendingTextRef.current?.timer) clearTimeout(pendingTextRef.current.timer);
1945
- },
1946
- [],
1947
- );
2008
+ }, [clearBufferedInput, paintQueuedFrames, props.frame]);
1948
2009
 
1949
2010
  const enqueue = useCallback(
1950
2011
  (
@@ -1953,20 +2014,24 @@ function BrowserViewport(props: {
1953
2014
  after?: (receipt: BrowserActionReceipt) => Promise<void>,
1954
2015
  ) => {
1955
2016
  const epoch = actionQueueEpochRef.current;
2017
+ const dispatch = actionRef.current;
1956
2018
  actionTailRef.current = actionTailRef.current
1957
2019
  .catch(() => undefined)
1958
2020
  .then(async () => {
1959
- if (epoch !== actionQueueEpochRef.current) return;
1960
- const receipt = await actionRef.current(action, frame);
2021
+ if (!mountedRef.current || epoch !== actionQueueEpochRef.current) return;
2022
+ const receipt = await dispatch(action, frame);
2023
+ if (!mountedRef.current || epoch !== actionQueueEpochRef.current) return;
1961
2024
  await after?.(receipt);
1962
2025
  })
1963
2026
  .catch((cause) => {
2027
+ if (!mountedRef.current || epoch !== actionQueueEpochRef.current) return;
1964
2028
  // Do not replay or continue input collected behind a failed request.
1965
2029
  actionQueueEpochRef.current += 1;
2030
+ clearBufferedInput();
1966
2031
  errorRef.current(cause);
1967
2032
  });
1968
2033
  },
1969
- [],
2034
+ [clearBufferedInput],
1970
2035
  );
1971
2036
 
1972
2037
  const point = useCallback(
@@ -1987,19 +2052,39 @@ function BrowserViewport(props: {
1987
2052
  lastClickRef.current = null;
1988
2053
  }, []);
1989
2054
 
2055
+ const flushPendingWheel = useCallback(() => {
2056
+ const batch = wheelRef.current;
2057
+ if (!batch) return;
2058
+ if (batch.timer) clearTimeout(batch.timer);
2059
+ wheelRef.current = null;
2060
+ enqueue(
2061
+ {
2062
+ type: "pointer",
2063
+ action: "scroll",
2064
+ x: batch.x,
2065
+ y: batch.y,
2066
+ deltaX: batch.deltaX,
2067
+ deltaY: batch.deltaY,
2068
+ },
2069
+ batch.frame,
2070
+ );
2071
+ }, [enqueue]);
2072
+
1990
2073
  const pointerDown = (event: PointerEvent<HTMLCanvasElement>) => {
1991
- if (!props.frame || event.button !== 0) return;
2074
+ const frame = paintedFrameRef.current;
2075
+ if (!frame || event.button !== 0) return;
1992
2076
  // Keep the hidden keyboard sink focused after the browser performs the
1993
2077
  // pointerdown default action for the canvas. Without preventing that
1994
2078
  // default, Chrome immediately moves focus back to the document and all
1995
2079
  // subsequent typing/paste is silently lost.
1996
2080
  event.preventDefault();
2081
+ flushPendingWheel();
1997
2082
  flushPendingText();
1998
2083
  pointerStartRef.current = {
1999
2084
  x: event.clientX,
2000
2085
  y: event.clientY,
2001
2086
  pointerId: event.pointerId,
2002
- frame: props.frame,
2087
+ frame,
2003
2088
  };
2004
2089
  event.currentTarget.setPointerCapture?.(event.pointerId);
2005
2090
  inputRef.current?.focus({ preventScroll: true });
@@ -2048,8 +2133,9 @@ function BrowserViewport(props: {
2048
2133
 
2049
2134
  const contextMenu = (event: MouseEvent<HTMLCanvasElement>) => {
2050
2135
  event.preventDefault();
2051
- const frame = props.frame;
2136
+ const frame = paintedFrameRef.current;
2052
2137
  if (!frame) return;
2138
+ flushPendingWheel();
2053
2139
  flushPendingText();
2054
2140
  flushPendingClick();
2055
2141
  const at = point(frame, event.clientX, event.clientY);
@@ -2057,41 +2143,35 @@ function BrowserViewport(props: {
2057
2143
  };
2058
2144
 
2059
2145
  const wheel = (event: WheelEvent<HTMLCanvasElement>) => {
2060
- const frame = props.frame;
2146
+ const frame = paintedFrameRef.current;
2061
2147
  if (!frame) return;
2062
2148
  const at = point(frame, event.clientX, event.clientY);
2063
2149
  if (!at) return;
2064
2150
  flushPendingText();
2065
2151
  flushPendingClick();
2066
2152
  event.preventDefault();
2067
- const pending = wheelRef.current;
2068
- if (pending?.timer) clearTimeout(pending.timer);
2153
+ let pending = wheelRef.current;
2154
+ if (
2155
+ pending &&
2156
+ (!sameBrowserDocument(pending.frame, frame) ||
2157
+ Math.hypot(pending.x - at.x, pending.y - at.y) > 6)
2158
+ ) {
2159
+ flushPendingWheel();
2160
+ pending = null;
2161
+ }
2069
2162
  wheelRef.current = {
2070
2163
  x: at.x,
2071
2164
  y: at.y,
2072
- deltaX: (pending && sameFrameFence(pending.frame, frame) ? pending.deltaX : 0) + event.deltaX,
2073
- deltaY: (pending && sameFrameFence(pending.frame, frame) ? pending.deltaY : 0) + event.deltaY,
2165
+ deltaX: (pending?.deltaX ?? 0) + event.deltaX,
2166
+ deltaY: (pending?.deltaY ?? 0) + event.deltaY,
2074
2167
  frame,
2075
- timer: setTimeout(() => {
2076
- const batch = wheelRef.current;
2077
- wheelRef.current = null;
2078
- if (batch)
2079
- enqueue(
2080
- {
2081
- type: "pointer",
2082
- action: "scroll",
2083
- x: batch.x,
2084
- y: batch.y,
2085
- deltaX: batch.deltaX,
2086
- deltaY: batch.deltaY,
2087
- },
2088
- batch.frame,
2089
- );
2090
- }, 45),
2168
+ // Retain the first event's deadline so a continuous gesture keeps moving.
2169
+ timer: pending?.timer ?? setTimeout(flushPendingWheel, 45),
2091
2170
  };
2092
2171
  };
2093
2172
 
2094
2173
  const keyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
2174
+ flushPendingWheel();
2095
2175
  const command = event.metaKey || event.ctrlKey;
2096
2176
  if (
2097
2177
  props.clipboardEnabled &&
@@ -2107,16 +2187,19 @@ function BrowserViewport(props: {
2107
2187
  event.preventDefault();
2108
2188
  flushPendingText();
2109
2189
  flushPendingClick();
2110
- enqueue({ type: "press", key }, props.frame);
2190
+ enqueue({ type: "press", key }, paintedFrameRef.current);
2111
2191
  };
2112
2192
 
2113
2193
  const copy = (event: ClipboardEvent<HTMLTextAreaElement>) => {
2114
2194
  if (!props.clipboardEnabled) return;
2115
2195
  event.preventDefault();
2196
+ flushPendingWheel();
2116
2197
  flushPendingText();
2117
2198
  flushPendingClick();
2118
- enqueue({ type: "clipboard", operation: "copy" }, props.frame, async () => {
2119
- const clipboard = await readClipboardRef.current();
2199
+ const readClipboard = readClipboardRef.current;
2200
+ enqueue({ type: "clipboard", operation: "copy" }, paintedFrameRef.current, async () => {
2201
+ const clipboard = await readClipboard();
2202
+ if (!mountedRef.current) return;
2120
2203
  if (clipboard.text.length === 0) return;
2121
2204
  if (!(await copyTextToClipboard(clipboard.text))) {
2122
2205
  throw new Error("Browser text could not be copied to the local clipboard");
@@ -2127,6 +2210,7 @@ function BrowserViewport(props: {
2127
2210
  const paste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
2128
2211
  if (!props.clipboardEnabled) return;
2129
2212
  event.preventDefault();
2213
+ flushPendingWheel();
2130
2214
  flushPendingText();
2131
2215
  flushPendingClick();
2132
2216
  enqueue(
@@ -2135,16 +2219,17 @@ function BrowserViewport(props: {
2135
2219
  operation: "paste",
2136
2220
  text: event.clipboardData.getData("text/plain"),
2137
2221
  },
2138
- props.frame,
2222
+ paintedFrameRef.current,
2139
2223
  );
2140
2224
  };
2141
2225
 
2142
2226
  const input = (value: string, nativeComposing = false) => {
2143
2227
  if (composingRef.current || nativeComposing) return;
2144
2228
  if (!value) return;
2229
+ flushPendingWheel();
2145
2230
  flushPendingClick();
2146
2231
  const pending = pendingTextRef.current;
2147
- if (pending && sameOptionalBrowserFrame(pending.frame, props.frame)) {
2232
+ if (pending && sameOptionalBrowserFrame(pending.frame, paintedFrameRef.current)) {
2148
2233
  clearTimeout(pending.timer);
2149
2234
  pending.text += value;
2150
2235
  pending.timer = setTimeout(flushPendingText, 16);
@@ -2152,7 +2237,7 @@ function BrowserViewport(props: {
2152
2237
  flushPendingText();
2153
2238
  pendingTextRef.current = {
2154
2239
  text: value,
2155
- frame: props.frame,
2240
+ frame: paintedFrameRef.current,
2156
2241
  timer: setTimeout(flushPendingText, 16),
2157
2242
  };
2158
2243
  }
@@ -2168,7 +2253,8 @@ function BrowserViewport(props: {
2168
2253
  input(event.currentTarget.value || event.data);
2169
2254
  };
2170
2255
 
2171
- const showCanvas = props.frame !== null;
2256
+ const showCanvas =
2257
+ props.frame !== null && paintedFrame !== null && sameBrowserDocument(props.frame, paintedFrame);
2172
2258
  return (
2173
2259
  <div className="relative min-h-0 flex-1 overflow-hidden bg-black">
2174
2260
  <canvas
@@ -2916,13 +3002,16 @@ function frameMatchesSelectedTarget(
2916
3002
  }
2917
3003
 
2918
3004
  function sameFrameFence(left: BrowserFrame, right: BrowserFrame): boolean {
3005
+ return sameBrowserDocument(left, right) && left.frameId === right.frameId;
3006
+ }
3007
+
3008
+ function sameBrowserDocument(left: BrowserFrame, right: BrowserFrame): boolean {
2919
3009
  return (
2920
3010
  left.browserSessionId === right.browserSessionId &&
2921
3011
  left.controllerGeneration === right.controllerGeneration &&
2922
3012
  left.targetId === right.targetId &&
2923
3013
  left.targetGeneration === right.targetGeneration &&
2924
- left.documentGeneration === right.documentGeneration &&
2925
- left.frameId === right.frameId
3014
+ left.documentGeneration === right.documentGeneration
2926
3015
  );
2927
3016
  }
2928
3017
 
@@ -248,6 +248,7 @@ export function ComposerTranscriptionControl({
248
248
  >
249
249
  <button
250
250
  type="button"
251
+ disabled={composer.disabled && !retrying}
251
252
  onClick={() =>
252
253
  savedTranscript
253
254
  ? void transcription.insertSavedTranscript()
@@ -265,7 +266,7 @@ export function ComposerTranscriptionControl({
265
266
  className={cn(
266
267
  "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
267
268
  "bg-og-fg text-og-bg transition-colors duration-150 motion-reduce:transition-none",
268
- "hover:bg-og-fg-muted pointer-coarse:size-11",
269
+ "hover:bg-og-fg-muted disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:size-11",
269
270
  )}
270
271
  >
271
272
  {savedTranscript ? (
@@ -280,12 +281,13 @@ export function ComposerTranscriptionControl({
280
281
  <Tip tip={messages.discardRecovered}>
281
282
  <button
282
283
  type="button"
284
+ disabled={composer.disabled}
283
285
  onClick={() => void transcription.discard()}
284
286
  aria-label={messages.discardRecovered}
285
287
  className={cn(
286
288
  "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
287
289
  "text-og-fg-muted transition-colors duration-150 motion-reduce:transition-none",
288
- "hover:bg-og-surface-3 hover:text-og-status-failed pointer-coarse:size-11",
290
+ "hover:bg-og-surface-3 hover:text-og-status-failed disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:size-11",
289
291
  )}
290
292
  >
291
293
  <Trash2Icon className="size-3.5" />
@@ -457,9 +457,9 @@ export function useChatComposerController({
457
457
  (event: DragEvent<HTMLDivElement>) => {
458
458
  if (!attachments || !dragCarriesFiles(event)) return;
459
459
  event.preventDefault();
460
- setDragging(true);
460
+ if (!disabled) setDragging(true);
461
461
  },
462
- [attachments],
462
+ [attachments, disabled],
463
463
  );
464
464
  const handleDragLeave = useCallback(
465
465
  (event: DragEvent<HTMLDivElement>) => {
@@ -474,11 +474,16 @@ export function useChatComposerController({
474
474
  if (!attachments || !dragCarriesFiles(event)) return;
475
475
  event.preventDefault();
476
476
  setDragging(false);
477
- if (event.dataTransfer.files.length > 0) attachments.addFiles(event.dataTransfer.files);
477
+ if (!disabled && event.dataTransfer.files.length > 0)
478
+ attachments.addFiles(event.dataTransfer.files);
478
479
  },
479
- [attachments],
480
+ [attachments, disabled],
480
481
  );
481
482
 
483
+ useEffect(() => {
484
+ if (disabled) setDragging(false);
485
+ }, [disabled]);
486
+
482
487
  const [notice, setNotice] = useState<Notice | null>(null);
483
488
  const [helpOpen, setHelpOpen] = useState(false);
484
489
  const [confirmState, setConfirmState] = useState<ConfirmState>(null);
@@ -624,17 +629,18 @@ export function useChatComposerController({
624
629
  );
625
630
  const handlePaste = useCallback(
626
631
  (event: ClipboardEvent<HTMLTextAreaElement>) => {
632
+ if (disabled) return;
627
633
  onPaste?.(event);
628
634
  attachments?.addFromPaste(event);
629
635
  },
630
- [attachments, onPaste],
636
+ [attachments, disabled, onPaste],
631
637
  );
632
638
  const handleFileChange = useCallback(
633
639
  (event: ChangeEvent<HTMLInputElement>) => {
634
- if (event.target.files) attachments?.addFiles(event.target.files);
640
+ if (!disabled && event.target.files) attachments?.addFiles(event.target.files);
635
641
  event.target.value = "";
636
642
  },
637
- [attachments],
643
+ [attachments, disabled],
638
644
  );
639
645
 
640
646
  const helpCommands = useMemo(
@@ -975,6 +981,7 @@ export function Attachments() {
975
981
  <AttachmentChips
976
982
  attachments={controller.attachments.attachments}
977
983
  messages={controller.messages}
984
+ disabled={controller.disabled}
978
985
  onRemove={controller.attachments.remove}
979
986
  onRetry={controller.attachments.retry}
980
987
  onRetainPreview={controller.attachments.retainPreview}
@@ -996,6 +1003,26 @@ type OwnedInputProps =
996
1003
  | "aria-keyshortcuts";
997
1004
  export type ComposerInputProps = Omit<ComponentPropsWithoutRef<"textarea">, OwnedInputProps>;
998
1005
 
1006
+ const EDITING_FOCUS_SELECTOR =
1007
+ 'input, textarea, select, [contenteditable="true"], [role="textbox"]';
1008
+ const POPUP_FOCUS_SELECTOR =
1009
+ '[role="menu"], [role="menubar"], [role="listbox"], [role="dialog"], [role="alertdialog"]';
1010
+
1011
+ /**
1012
+ * Initial focus is a convenience, not authority to move focus the person has
1013
+ * placed elsewhere. It yields to a field they started editing and to an open
1014
+ * menu, listbox, or dialog that does not contain the composer: moving focus
1015
+ * behind a non-modal popup dismisses it, and a modal surface owns focus until
1016
+ * it closes.
1017
+ */
1018
+ function focusBelongsElsewhere(textarea: HTMLTextAreaElement): boolean {
1019
+ const active = textarea.ownerDocument.activeElement;
1020
+ if (!active || active === textarea) return false;
1021
+ if (active.closest(EDITING_FOCUS_SELECTOR)) return true;
1022
+ const popup = active.closest(POPUP_FOCUS_SELECTOR);
1023
+ return popup !== null && !popup.contains(textarea);
1024
+ }
1025
+
999
1026
  export const Input = forwardRef<HTMLTextAreaElement, ComposerInputProps>(function ComposerInput(
1000
1027
  { rows = 1, placeholder, className, "aria-label": ariaLabel, autoFocus = false, ...props },
1001
1028
  forwardedRef,
@@ -1005,22 +1032,17 @@ export const Input = forwardRef<HTMLTextAreaElement, ComposerInputProps>(functio
1005
1032
  const paletteOpen =
1006
1033
  controller.paletteEnabled && controller.paletteMounted && controller.palette.open;
1007
1034
 
1008
- // Native autoFocus loses when the textarea mounts disabled (create-session draft
1009
- // hydrate). Retry once the controller becomes interactive.
1035
+ // Focus once the controller is interactive. The textarea can mount disabled
1036
+ // (create-session draft hydrate), and the route can finish loading after the
1037
+ // person has already moved on, so this deliberately does not use the native
1038
+ // autoFocus attribute: that would take focus at mount without the guard.
1010
1039
  useEffect(() => {
1011
1040
  if (!autoFocus || controller.disabled || autoFocusedRef.current) return;
1012
1041
  const frame = window.requestAnimationFrame(() => {
1013
1042
  const textarea = controller.textareaRef.current;
1014
1043
  if (!textarea || textarea.disabled) return;
1015
1044
  autoFocusedRef.current = true;
1016
- // Draft hydration may finish after the user has started editing another
1017
- // field. Initial focus is a convenience, not authority to take their caret.
1018
- const active = textarea.ownerDocument.activeElement;
1019
- if (
1020
- active !== textarea &&
1021
- active?.closest('input, textarea, select, [contenteditable="true"], [role="textbox"]')
1022
- )
1023
- return;
1045
+ if (focusBelongsElsewhere(textarea)) return;
1024
1046
  textarea.focus();
1025
1047
  });
1026
1048
  return () => window.cancelAnimationFrame(frame);
@@ -1041,7 +1063,6 @@ export const Input = forwardRef<HTMLTextAreaElement, ComposerInputProps>(functio
1041
1063
  : (placeholder ?? controller.messages.messagePlaceholder)
1042
1064
  }
1043
1065
  disabled={controller.disabled}
1044
- autoFocus={autoFocus && !controller.disabled}
1045
1066
  aria-label={ariaLabel ?? controller.messages.inputLabel}
1046
1067
  aria-keyshortcuts="Enter Meta+Enter Control+Enter Shift+Enter"
1047
1068
  aria-autocomplete={
@@ -1679,6 +1700,7 @@ function ConfirmBar({
1679
1700
  function AttachmentChips({
1680
1701
  attachments,
1681
1702
  messages,
1703
+ disabled,
1682
1704
  onRemove,
1683
1705
  onRetry,
1684
1706
  onRetainPreview,
@@ -1686,6 +1708,7 @@ function AttachmentChips({
1686
1708
  }: {
1687
1709
  attachments: UseFileAttachmentsResult["attachments"];
1688
1710
  messages: ChatComposerMessages;
1711
+ disabled: boolean;
1689
1712
  onRemove: (id: string) => void;
1690
1713
  onRetry?: ((id: string) => void) | undefined;
1691
1714
  onRetainPreview: UseFileAttachmentsResult["retainPreview"];
@@ -1837,7 +1860,8 @@ function AttachmentChips({
1837
1860
  <button
1838
1861
  type="button"
1839
1862
  onClick={() => onRetry(attachment.id)}
1840
- className="shrink-0 rounded-og-xs p-1 text-og-fg-muted hover:bg-og-surface-1 hover:text-og-fg pointer-coarse:size-10"
1863
+ disabled={disabled}
1864
+ className="shrink-0 rounded-og-xs p-1 text-og-fg-muted hover:bg-og-surface-1 hover:text-og-fg disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:size-10"
1841
1865
  aria-label={messages.retryAttachment(attachment.name)}
1842
1866
  >
1843
1867
  <RotateCwIcon className="size-3.5" />
@@ -1847,7 +1871,8 @@ function AttachmentChips({
1847
1871
  <button
1848
1872
  type="button"
1849
1873
  onClick={() => onRemove(attachment.id)}
1850
- className="shrink-0 rounded-og-xs p-1 text-og-fg-muted hover:bg-og-surface-1 hover:text-og-fg pointer-coarse:size-10"
1874
+ disabled={disabled}
1875
+ className="shrink-0 rounded-og-xs p-1 text-og-fg-muted hover:bg-og-surface-1 hover:text-og-fg disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:size-10"
1851
1876
  aria-label={messages.removeAttachment(attachment.name)}
1852
1877
  >
1853
1878
  <XIcon className="size-3.5" />