@coffer-org/plugin-webchat 6.0.0 → 7.0.1

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.
@@ -1,5 +1,5 @@
1
1
  import * as __fedReact from "react";
2
- import { Dialog, DialogContent, classifySiteHref, uploadFile, useMinWidth } from "@coffer-org/web-ui";
2
+ import { Dialog, DialogContent, Icon, classifySiteHref, toneVar, uploadFile, useMinWidth } from "@coffer-org/web-ui";
3
3
  import { createContext, createElement, forwardRef, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { useLocation, useNavigate } from "react-router-dom";
5
5
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
@@ -466,7 +466,7 @@ var useLucideContext = () => useContext(LucideContext);
466
466
  * This source code is licensed under the ISC license.
467
467
  * See the LICENSE file in the root directory of this source tree.
468
468
  */
469
- var Icon = forwardRef(({ color, size, strokeWidth, absoluteStrokeWidth, className = "", children, iconNode, ...rest }, ref) => {
469
+ var Icon$1 = forwardRef(({ color, size, strokeWidth, absoluteStrokeWidth, className = "", children, iconNode, ...rest }, ref) => {
470
470
  const { size: contextSize = 24, strokeWidth: contextStrokeWidth = 2, absoluteStrokeWidth: contextAbsoluteStrokeWidth = false, color: contextColor = "currentColor", className: contextClass = "" } = useLucideContext() ?? {};
471
471
  const calculatedStrokeWidth = absoluteStrokeWidth ?? contextAbsoluteStrokeWidth ? Number(strokeWidth ?? contextStrokeWidth) * 24 / Number(size ?? contextSize) : strokeWidth ?? contextStrokeWidth;
472
472
  return createElement("svg", {
@@ -490,7 +490,7 @@ var Icon = forwardRef(({ color, size, strokeWidth, absoluteStrokeWidth, classNam
490
490
  * See the LICENSE file in the root directory of this source tree.
491
491
  */
492
492
  var createLucideIcon = (iconName, iconNode) => {
493
- const Component = forwardRef(({ className, ...props }, ref) => createElement(Icon, {
493
+ const Component = forwardRef(({ className, ...props }, ref) => createElement(Icon$1, {
494
494
  ref,
495
495
  iconNode,
496
496
  className: mergeClasses(`lucide-${toKebabCase(toPascalCase(iconName))}`, `lucide-${iconName}`, className),
@@ -802,8 +802,9 @@ async function* readSse(body) {
802
802
  * separate authorization here; the gate is on the server. */
803
803
  var BASE = "/api/plugins/webchat/user";
804
804
  /** POST a member-level webchat action with a JSON body: the one request path every call
805
- * in this file (except the SSE stream in `sendMessage`) goes through. Throws `Error(action)`
806
- * on any non-OK response — every caller below relies on that to build its own error message. */
805
+ * in this file (except the SSE stream in `openChannel` and `stopTurn`'s empty-body POST)
806
+ * goes through. Throws `Error(action)` on any non-OK response — every caller below relies
807
+ * on that to build its own error message. */
807
808
  async function postAction(action, body) {
808
809
  const r = await fetch(`${BASE}/${action}`, {
809
810
  method: "POST",
@@ -842,35 +843,98 @@ async function selectAgent(convId, patch) {
842
843
  ...patch
843
844
  })).selection;
844
845
  }
846
+ /** Mark a conversation private, or reopen it. Only ever meaningful for the
847
+ * caller's OWN conversation — the server refuses (returns the unchanged
848
+ * visibility) for anything else, so the UI only offers this control on the
849
+ * viewer's own rows in the first place. */
850
+ async function setThreadVisibility(convId, makePrivate) {
851
+ return postAction("setVisibility", {
852
+ convId,
853
+ private: makePrivate
854
+ });
855
+ }
856
+ /** Records the user turn and starts the agent turn running — an ordinary POST that
857
+ * resolves as soon as the turn has STARTED, not once it finishes (`sendAction`,
858
+ * `../runtime/send.ts`). It streams nothing any more: every live update — the
859
+ * answer as it is written, its completion, an error — arrives on the
860
+ * conversation's own channel (`openChannel`, below), which `use-chat.ts` keeps
861
+ * open for as long as this conversation is on screen, whether or not THIS call
862
+ * is what started the turn currently running. */
845
863
  async function sendMessage(req) {
846
- const r = await fetch(`${BASE}/send/stream`, {
864
+ return postAction("send", {
865
+ convId: req.convId,
866
+ text: req.text,
867
+ replyTo: req.replyTo,
868
+ context: req.context,
869
+ ...req.attachments?.length ? { attachments: req.attachments } : {}
870
+ });
871
+ }
872
+ /** Abort the turn currently running on `convId`, if there is one (`stopAction`,
873
+ * `../runtime/actions.ts`). That action returns `Promise<void>` — the route sends
874
+ * an empty 200 body, the same uniform non-answer whether a live turn was
875
+ * actually aborted, none was running, or the caller may not write to the
876
+ * conversation — so this deliberately does NOT go through `postAction`, whose
877
+ * `.json()` would throw on that empty body. */
878
+ async function stopTurn(convId) {
879
+ if (!(await fetch(`${BASE}/stop`, {
847
880
  method: "POST",
848
881
  headers: { "content-type": "application/json" },
849
- body: JSON.stringify({
850
- convId: req.convId,
851
- text: req.text,
852
- replyTo: req.replyTo,
853
- context: req.context,
854
- ...req.attachments?.length ? { attachments: req.attachments } : {}
855
- }),
856
- signal: req.signal
882
+ body: JSON.stringify({ convId })
883
+ })).ok) throw new Error("stop");
884
+ }
885
+ /** Subscribe to `convId`'s channel and hold the connection open, delivering every
886
+ * frame to `handlers` until the server ends the stream (the conversation went
887
+ * private on this viewer, `setVisibility`'s eviction; a restart; a network drop)
888
+ * or `signal` aborts it. Resolves (does not reject) when the server ends the
889
+ * stream cleanly, same as a plain fetch whose body simply runs out — rejects only
890
+ * on a genuine transport failure or `signal` aborting. Either way it is
891
+ * `use-chat.ts`'s job to reconnect with backoff while the conversation is still
892
+ * the one on screen: there is no `Last-Event-ID` bookkeeping and no server-side
893
+ * buffer here, by design (see the design doc's "Reconnect, and the race that
894
+ * comes with it") — missed frames are recovered by re-reading history, never by
895
+ * replaying the stream. */
896
+ async function openChannel(convId, handlers, signal) {
897
+ const r = await fetch(`${BASE}/channel/stream`, {
898
+ method: "POST",
899
+ headers: { "content-type": "application/json" },
900
+ body: JSON.stringify({ convId }),
901
+ signal
857
902
  });
858
- if (!r.ok || !r.body) {
859
- req.onError(`http ${r.status}`);
860
- return;
861
- }
903
+ if (!r.ok || !r.body) throw new Error(`http ${r.status}`);
862
904
  for await (const frame of readSse(r.body)) try {
863
905
  const data = JSON.parse(frame.data);
864
- if (frame.event === "reasoning") req.onReasoning?.(String(data["text"] ?? ""));
865
- else if (frame.event === "delta" || frame.event === "message") req.onDelta(String(data["text"] ?? ""));
866
- else if (frame.event === "done") req.onDone({
906
+ if (frame.event === "user") handlers.onUser({
907
+ msgId: String(data["msgId"] ?? ""),
908
+ text: String(data["text"] ?? ""),
909
+ ts: typeof data["ts"] === "number" ? data["ts"] : 0,
910
+ ...Array.isArray(data["attachments"]) ? { attachments: data["attachments"] } : {}
911
+ });
912
+ else if (frame.event === "message") handlers.onMessage({
913
+ msgId: String(data["msgId"] ?? ""),
914
+ text: String(data["text"] ?? "")
915
+ });
916
+ else if (frame.event === "delta") handlers.onDelta({
917
+ msgId: String(data["msgId"] ?? ""),
918
+ text: String(data["text"] ?? "")
919
+ });
920
+ else if (frame.event === "reasoning") handlers.onReasoning({
921
+ msgId: String(data["msgId"] ?? ""),
922
+ text: String(data["text"] ?? "")
923
+ });
924
+ else if (frame.event === "done") handlers.onDone({
867
925
  msgId: data["msgId"] ?? null,
868
926
  parentMsgId: String(data["parentMsgId"] ?? ""),
869
927
  suggestions: Array.isArray(data["suggestions"]) ? data["suggestions"] : null
870
928
  });
871
- else if (frame.event === "error") req.onError(String(data["message"] ?? "error"));
929
+ else if (frame.event === "error") handlers.onError({
930
+ message: data["message"] ?? null,
931
+ parentMsgId: typeof data["parentMsgId"] === "string" ? data["parentMsgId"] : null
932
+ });
872
933
  } catch (e) {
873
- req.onError(`malformed frame: ${String(e)}`);
934
+ handlers.onError({
935
+ message: `malformed frame: ${String(e)}`,
936
+ parentMsgId: null
937
+ });
874
938
  }
875
939
  }
876
940
  //#endregion
@@ -878,6 +942,23 @@ async function sendMessage(req) {
878
942
  function newId() {
879
943
  return crypto.randomUUID();
880
944
  }
945
+ /** The message id a frame reports as its OUTCOME, for dedup against history on
946
+ * flush. `error` is never persisted (a failed turn stores nothing), so it is
947
+ * never a duplicate of anything a history read could carry — its
948
+ * `parentMsgId` exists only to scope the send-lock clear below to the
949
+ * viewer's own turn, a different question from dedup. */
950
+ function frameHistoryId(frame) {
951
+ switch (frame.kind) {
952
+ case "user":
953
+ case "message":
954
+ case "delta":
955
+ case "reasoning": return frame.data.msgId;
956
+ case "done": return frame.data.msgId;
957
+ case "error": return null;
958
+ }
959
+ }
960
+ var RECONNECT_BASE_MS = 1e3;
961
+ var RECONNECT_MAX_MS = 3e4;
881
962
  function useChat() {
882
963
  const [threads, setThreads] = useState([]);
883
964
  const [convId, setConvId] = useState(null);
@@ -894,15 +975,16 @@ function useChat() {
894
975
  const [starters, setStarters] = useState([]);
895
976
  const [suggestions, setSuggestions] = useState(null);
896
977
  const headRef = useRef(null);
897
- const abortRef = useRef(null);
898
978
  const sendingRef = useRef(false);
979
+ const myTurnMsgIdRef = useRef(null);
980
+ const pendingTextRef = useRef("");
981
+ const pendingReasoningRef = useRef(null);
899
982
  const turnRef = useRef(0);
900
983
  const aliveRef = useRef(true);
901
984
  useEffect(() => {
902
985
  aliveRef.current = true;
903
986
  return () => {
904
987
  aliveRef.current = false;
905
- abortRef.current?.abort();
906
988
  turnRef.current++;
907
989
  };
908
990
  }, []);
@@ -925,6 +1007,157 @@ function useChat() {
925
1007
  cancelled = true;
926
1008
  };
927
1009
  }, [convId]);
1010
+ /** Apply one channel frame to state. Called either live (as it arrives, once a
1011
+ * connection has finished its history flush) or from the buffer a fresh
1012
+ * (re)connect built while history was in flight — `historyIds` is non-null
1013
+ * ONLY for the latter, and gates every frame on it: a frame whose outcome the
1014
+ * just-fetched history already carries is dropped rather than reapplied, the
1015
+ * one piece of ordering the design doc calls out by name. Live frames never
1016
+ * need this check — their message id cannot already be in a history snapshot
1017
+ * read before they existed. */
1018
+ const applyFrame = useCallback((frame, historyIds) => {
1019
+ const outcomeId = frameHistoryId(frame);
1020
+ const isDuplicate = historyIds !== null && outcomeId !== null && historyIds.has(outcomeId);
1021
+ switch (frame.kind) {
1022
+ case "user": {
1023
+ if (isDuplicate) break;
1024
+ const { msgId, text, ts, attachments } = frame.data;
1025
+ setMessages((prev) => prev.some((m) => m.msgId === msgId) ? prev : [...prev, {
1026
+ msgId,
1027
+ role: "user",
1028
+ text,
1029
+ ts,
1030
+ ...attachments?.length ? { attachments } : {}
1031
+ }]);
1032
+ break;
1033
+ }
1034
+ case "message":
1035
+ case "delta":
1036
+ if (isDuplicate) break;
1037
+ pendingTextRef.current = frame.data.text;
1038
+ setPending(frame.data.text);
1039
+ break;
1040
+ case "reasoning":
1041
+ if (isDuplicate) break;
1042
+ pendingReasoningRef.current = frame.data.text;
1043
+ setPendingReasoning(frame.data.text);
1044
+ break;
1045
+ case "done": {
1046
+ const { msgId, parentMsgId, suggestions: nextSuggestions } = frame.data;
1047
+ headRef.current = msgId ?? parentMsgId;
1048
+ setSuggestions(nextSuggestions ?? null);
1049
+ if (!isDuplicate && msgId && pendingTextRef.current) {
1050
+ const text = pendingTextRef.current;
1051
+ const reasoning = pendingReasoningRef.current;
1052
+ setMessages((prev) => prev.some((m) => m.msgId === msgId) ? prev : [...prev, {
1053
+ msgId,
1054
+ role: "assistant",
1055
+ text,
1056
+ ts: Math.floor(Date.now() / 1e3),
1057
+ ...reasoning ? { reasoning } : {}
1058
+ }]);
1059
+ }
1060
+ pendingTextRef.current = "";
1061
+ pendingReasoningRef.current = null;
1062
+ setPending(null);
1063
+ setPendingReasoning(null);
1064
+ if (myTurnMsgIdRef.current !== null && myTurnMsgIdRef.current === parentMsgId) {
1065
+ sendingRef.current = false;
1066
+ myTurnMsgIdRef.current = null;
1067
+ }
1068
+ loadThreads();
1069
+ break;
1070
+ }
1071
+ case "error":
1072
+ setError(frame.data.message ?? "error");
1073
+ pendingTextRef.current = "";
1074
+ pendingReasoningRef.current = null;
1075
+ setPending(null);
1076
+ setPendingReasoning(null);
1077
+ if (myTurnMsgIdRef.current !== null && myTurnMsgIdRef.current === frame.data.parentMsgId) {
1078
+ sendingRef.current = false;
1079
+ myTurnMsgIdRef.current = null;
1080
+ }
1081
+ break;
1082
+ }
1083
+ }, [loadThreads]);
1084
+ useEffect(() => {
1085
+ if (!convId) return;
1086
+ const chatConvId = convId;
1087
+ let cancelled = false;
1088
+ let reconnectTimer = null;
1089
+ let attempt = 0;
1090
+ const ctl = new AbortController();
1091
+ async function connectOnce() {
1092
+ if (cancelled) return;
1093
+ let buffering = true;
1094
+ let receivedFrame = false;
1095
+ const buffer = [];
1096
+ const dispatch = (frame) => {
1097
+ if (cancelled) return;
1098
+ receivedFrame = true;
1099
+ if (buffering) buffer.push(frame);
1100
+ else applyFrame(frame, null);
1101
+ };
1102
+ const settled = openChannel(chatConvId, {
1103
+ onUser: (data) => dispatch({
1104
+ kind: "user",
1105
+ data
1106
+ }),
1107
+ onMessage: (data) => dispatch({
1108
+ kind: "message",
1109
+ data
1110
+ }),
1111
+ onDelta: (data) => dispatch({
1112
+ kind: "delta",
1113
+ data
1114
+ }),
1115
+ onReasoning: (data) => dispatch({
1116
+ kind: "reasoning",
1117
+ data
1118
+ }),
1119
+ onDone: (data) => dispatch({
1120
+ kind: "done",
1121
+ data
1122
+ }),
1123
+ onError: (data) => dispatch({
1124
+ kind: "error",
1125
+ data
1126
+ })
1127
+ }, ctl.signal).then(() => true, () => false);
1128
+ try {
1129
+ const h = await fetchHistory(chatConvId);
1130
+ if (cancelled) return;
1131
+ headRef.current = h.headMsgId;
1132
+ const historyIds = new Set(h.messages.map((m) => m.msgId));
1133
+ setMessages((prev) => {
1134
+ const ownPendingId = myTurnMsgIdRef.current;
1135
+ const stillUnconfirmed = prev.filter((m) => !historyIds.has(m.msgId) && (m.msgId.startsWith("local-") || m.msgId === ownPendingId));
1136
+ return stillUnconfirmed.length ? [...h.messages, ...stillUnconfirmed] : h.messages;
1137
+ });
1138
+ setSuggestions(h.messages.at(-1)?.suggestions ?? null);
1139
+ buffering = false;
1140
+ for (const frame of buffer) applyFrame(frame, historyIds);
1141
+ } catch (e) {
1142
+ if (cancelled) return;
1143
+ setError(e.message);
1144
+ buffering = false;
1145
+ for (const frame of buffer) applyFrame(frame, null);
1146
+ }
1147
+ await settled;
1148
+ if (cancelled) return;
1149
+ if (receivedFrame) attempt = 0;
1150
+ const delay = Math.min(RECONNECT_BASE_MS * 2 ** attempt, RECONNECT_MAX_MS);
1151
+ attempt++;
1152
+ reconnectTimer = setTimeout(() => void connectOnce(), delay);
1153
+ }
1154
+ connectOnce();
1155
+ return () => {
1156
+ cancelled = true;
1157
+ if (reconnectTimer) clearTimeout(reconnectTimer);
1158
+ ctl.abort();
1159
+ };
1160
+ }, [convId, applyFrame]);
928
1161
  const chooseAgent = useCallback((next) => {
929
1162
  const previous = selection;
930
1163
  setSelection(next);
@@ -933,52 +1166,61 @@ function useChat() {
933
1166
  setError(err instanceof Error ? err.message : String(err));
934
1167
  });
935
1168
  }, [convId, selection]);
1169
+ const togglePrivate = useCallback((convId, next) => {
1170
+ const previous = threads.find((t) => t.convId === convId)?.private ?? false;
1171
+ setThreads((prev) => prev.map((t) => t.convId === convId ? {
1172
+ ...t,
1173
+ private: next
1174
+ } : t));
1175
+ setThreadVisibility(convId, next).then((r) => {
1176
+ setThreads((prev) => prev.map((t) => t.convId === convId ? {
1177
+ ...t,
1178
+ private: r.visibility === "private"
1179
+ } : t));
1180
+ }).catch(() => {
1181
+ setThreads((prev) => prev.map((t) => t.convId === convId ? {
1182
+ ...t,
1183
+ private: previous
1184
+ } : t));
1185
+ });
1186
+ }, [threads]);
936
1187
  const attachmentsAllowed = useMemo(() => {
937
- const preset = agents.find((a) => a.id === selection.agentId)?.presets.find((p) => p.id === selection.presetId);
938
- if (!preset?.capabilities) return true;
939
- return preset.capabilities.vision !== false || preset.capabilities.documents !== false;
1188
+ const media = agents.find((a) => a.id === selection.agentId)?.media;
1189
+ if (!media) return true;
1190
+ return media.image.accepts.length > 0 || media.document.accepts.length > 0;
940
1191
  }, [agents, selection]);
941
1192
  const newConversation = useCallback(() => {
942
- abortRef.current?.abort();
943
- abortRef.current = null;
944
1193
  turnRef.current++;
1194
+ sendingRef.current = false;
1195
+ myTurnMsgIdRef.current = null;
945
1196
  headRef.current = null;
946
1197
  setConvId(newId());
947
1198
  setMessages([]);
948
1199
  setPending(null);
949
1200
  setPendingReasoning(null);
1201
+ pendingTextRef.current = "";
1202
+ pendingReasoningRef.current = null;
950
1203
  setError(null);
951
1204
  setSuggestions(null);
952
1205
  }, []);
953
1206
  const open = useCallback(async (id) => {
954
- abortRef.current?.abort();
955
- abortRef.current = null;
956
1207
  turnRef.current++;
957
- const myTurn = turnRef.current;
1208
+ sendingRef.current = false;
1209
+ myTurnMsgIdRef.current = null;
958
1210
  setConvId(id);
959
- setMessages([]);
960
1211
  headRef.current = null;
1212
+ setMessages([]);
961
1213
  setPending(null);
962
1214
  setPendingReasoning(null);
1215
+ pendingTextRef.current = "";
1216
+ pendingReasoningRef.current = null;
963
1217
  setError(null);
964
1218
  setSuggestions(null);
965
- try {
966
- const h = await fetchHistory(id);
967
- if (turnRef.current !== myTurn) return;
968
- headRef.current = h.headMsgId;
969
- setMessages(h.messages);
970
- setSuggestions(h.messages.at(-1)?.suggestions ?? null);
971
- } catch (e) {
972
- if (turnRef.current !== myTurn) return;
973
- setError(e.message);
974
- }
975
1219
  }, []);
976
1220
  const stop = useCallback(() => {
977
- abortRef.current?.abort();
978
- abortRef.current = null;
979
- setPending(null);
980
- setPendingReasoning(null);
981
- }, []);
1221
+ if (!convId) return;
1222
+ stopTurn(convId).catch(() => {});
1223
+ }, [convId]);
982
1224
  return {
983
1225
  threads,
984
1226
  convId,
@@ -993,6 +1235,7 @@ function useChat() {
993
1235
  suggestions,
994
1236
  attachmentsAllowed,
995
1237
  chooseAgent,
1238
+ togglePrivate,
996
1239
  send: useCallback((text, context, attachments = []) => {
997
1240
  if (!text.trim() || sendingRef.current) return false;
998
1241
  sendingRef.current = true;
@@ -1001,74 +1244,47 @@ function useChat() {
1001
1244
  if (convId === null) setConvId(id);
1002
1245
  setError(null);
1003
1246
  setSuggestions(null);
1247
+ const localMsgId = `local-${newId()}`;
1004
1248
  setMessages((prev) => [...prev, {
1005
- msgId: `local-${newId()}`,
1249
+ msgId: localMsgId,
1006
1250
  role: "user",
1007
1251
  text,
1008
1252
  ts: Math.floor(Date.now() / 1e3),
1009
1253
  ...attachments.length ? { attachments } : {}
1010
1254
  }]);
1011
1255
  setPending("");
1012
- const ctl = new AbortController();
1013
- abortRef.current = ctl;
1014
- let acc = "";
1015
- let accReasoning = "";
1016
1256
  (async () => {
1017
1257
  try {
1018
- await sendMessage({
1258
+ const { msgId } = await sendMessage({
1019
1259
  convId: id,
1020
1260
  text,
1021
1261
  replyTo: headRef.current,
1022
1262
  context,
1023
- attachments,
1024
- signal: ctl.signal,
1025
- onDelta: (t) => {
1026
- if (turnRef.current !== myTurn) return;
1027
- acc = t;
1028
- setPending(t);
1029
- },
1030
- onReasoning: (t) => {
1031
- if (turnRef.current !== myTurn) return;
1032
- accReasoning = t;
1033
- setPendingReasoning(t);
1034
- },
1035
- onDone: ({ msgId, parentMsgId, suggestions: nextSuggestions }) => {
1036
- if (turnRef.current !== myTurn) return;
1037
- headRef.current = msgId ?? parentMsgId;
1038
- setSuggestions(nextSuggestions ?? null);
1039
- if (acc) setMessages((prev) => [...prev, {
1040
- msgId: msgId ?? `local-${newId()}`,
1041
- role: "assistant",
1042
- text: acc,
1043
- ts: Math.floor(Date.now() / 1e3),
1044
- ...accReasoning ? { reasoning: accReasoning } : {}
1045
- }]);
1046
- setPending(null);
1047
- setPendingReasoning(null);
1048
- },
1049
- onError: (message) => {
1050
- if (turnRef.current !== myTurn) return;
1051
- setError(message);
1052
- setPending(null);
1053
- setPendingReasoning(null);
1054
- }
1263
+ attachments
1055
1264
  });
1056
- } catch (e) {
1057
- if (turnRef.current === myTurn) {
1058
- if (e.name !== "AbortError") setError(e.message);
1265
+ if (turnRef.current !== myTurn) return;
1266
+ if (msgId === null) {
1267
+ sendingRef.current = false;
1059
1268
  setPending(null);
1060
1269
  setPendingReasoning(null);
1270
+ return;
1061
1271
  }
1062
- } finally {
1063
- sendingRef.current = false;
1272
+ setMessages((prev) => prev.some((m) => m.msgId === msgId) ? prev.filter((m) => m.msgId !== localMsgId) : prev.map((m) => m.msgId === localMsgId ? {
1273
+ ...m,
1274
+ msgId
1275
+ } : m));
1276
+ myTurnMsgIdRef.current = msgId;
1277
+ } catch (e) {
1064
1278
  if (turnRef.current === myTurn) {
1065
- abortRef.current = null;
1066
- loadThreads();
1279
+ sendingRef.current = false;
1280
+ setError(e.message);
1281
+ setPending(null);
1282
+ setPendingReasoning(null);
1067
1283
  }
1068
1284
  }
1069
1285
  })();
1070
1286
  return true;
1071
- }, [convId, loadThreads]),
1287
+ }, [convId]),
1072
1288
  open,
1073
1289
  newConversation,
1074
1290
  loadThreads,
@@ -1077,10 +1293,47 @@ function useChat() {
1077
1293
  }
1078
1294
  //#endregion
1079
1295
  //#region src/ui/use-page-context.ts
1080
- /** Current-page context for the agent. SPA routes: `/:library/:type[/:id]`;
1081
- * system branches (settings, browse) are returned by path only. */
1082
- function usePageContext() {
1083
- const { pathname } = useLocation();
1296
+ /** The record page's own heading — a BEST-EFFORT read of the app's own rendered DOM,
1297
+ * not a data path. `RecordContent` (`packages/web/display/index.tsx`) marks the
1298
+ * TITLE element itself `role="heading" aria-level="1"` (the row around it, which also
1299
+ * holds the avatar, carries no role — otherwise the avatar's own text would be read
1300
+ * as part of the name); today that is the only element in the whole app carrying it,
1301
+ * so this selector cannot pick up some OTHER record's heading by mistake. It exists at
1302
+ * all because the chat overlay is a sibling of the routed page (mounted in the app
1303
+ * shell's own slot), never a descendant of it, so there is no React tree — no prop, no
1304
+ * context — to read the value from, and this package (`plugin-webchat`) has no data
1305
+ * path of its own to the open record either. Reading the painted DOM instead of
1306
+ * fetching the record: the SPA already has it open and displaying this text, and a
1307
+ * server-side lookup would be a query per turn for a value the browser is holding.
1308
+ *
1309
+ * WHEN it is read is the whole safety argument, so it is stated rather than assumed:
1310
+ * reading the DOM during React's RENDER phase sees the PREVIOUSLY COMMITTED tree, so a
1311
+ * navigation from one record to another produced the new path and id beside the OLD
1312
+ * record's name — a fact stated WRONG, which the base prompt tells the model to trust.
1313
+ * Hence `usePageContext` returns a READER the composer calls in its send handler: an
1314
+ * event handler runs after commit, so the heading and the path it is reported with
1315
+ * come from the same painted page. That is also what the design asks for — the page
1316
+ * the user was on AT THE MOMENT OF SENDING, and nowhere else.
1317
+ *
1318
+ * What is guaranteed, then: the title is read from the DOM as it stands when the turn
1319
+ * is sent. What is NOT guaranteed is that the title is present — a heading not yet
1320
+ * painted (a record still loading) and an untitled record's Dash placeholder both
1321
+ * yield no title, and an absent fact is the harmless case. One case yields a WRONG
1322
+ * one: if the app ever grows a SECOND element with this exact role/level (a preview
1323
+ * dialog, a relation picker, a plugin's own record renderer), `querySelector` returns
1324
+ * whichever comes first in document order, not necessarily the record the user is
1325
+ * actually looking at. Whoever eventually gives the overlay a real data path to the
1326
+ * open record should read this as the reason it was ever DOM-based, not rediscover it. */
1327
+ var EMPTY_TITLE = "—";
1328
+ function recordHeading() {
1329
+ if (typeof document === "undefined") return void 0;
1330
+ const text = document.querySelector("[role=\"heading\"][aria-level=\"1\"]")?.textContent?.trim();
1331
+ return text && text !== EMPTY_TITLE ? text : void 0;
1332
+ }
1333
+ /** SPA routes: `/:library/:type[/:id]`; system branches (settings, browse) are
1334
+ * returned by path only. Pure but for the heading read, which is why it takes the
1335
+ * path rather than calling the router itself. */
1336
+ function pageContextAt(pathname) {
1084
1337
  const parts = pathname.split("/").filter(Boolean);
1085
1338
  const system = /* @__PURE__ */ new Set([
1086
1339
  "settings",
@@ -1091,13 +1344,25 @@ function usePageContext() {
1091
1344
  ]);
1092
1345
  if (parts.length < 2 || system.has(parts[0])) return { path: pathname };
1093
1346
  const [library, type, id] = parts;
1347
+ const isRecord = Boolean(id && id !== "new");
1348
+ const title = isRecord ? recordHeading() : void 0;
1094
1349
  return {
1095
1350
  path: pathname,
1096
1351
  library,
1097
1352
  type,
1098
- ...id && id !== "new" ? { id } : {}
1353
+ ...isRecord ? { id } : {},
1354
+ ...title ? { title } : {}
1099
1355
  };
1100
1356
  }
1357
+ /** Current-page context for the agent, as a reader called AT SEND TIME rather than a
1358
+ * value computed while rendering — see the heading comment above for why that
1359
+ * distinction is the difference between an absent title and a wrong one. The path
1360
+ * comes from the render that is currently committed, which is the same tree the
1361
+ * reader's heading lookup walks. */
1362
+ function usePageContext() {
1363
+ const { pathname } = useLocation();
1364
+ return useCallback(() => pageContextAt(pathname), [pathname]);
1365
+ }
1101
1366
  //#endregion
1102
1367
  //#region ../../node_modules/devlop/lib/development.js
1103
1368
  var AssertionError = class extends Error {
@@ -23819,11 +24084,11 @@ function ChatMessages({ messages, pending, pendingReasoning, trailing }) {
23819
24084
  var Bubble = memo(function Bubble({ role, text, reasoning, attachments }) {
23820
24085
  const { t } = useTranslation();
23821
24086
  if (role === "user") return /* @__PURE__ */ jsxs("div", {
23822
- className: "self-end max-w-[85%] rounded-2xl bg-accent/15 px-3 py-2 text-[0.8125rem] whitespace-pre-wrap break-words",
24087
+ className: "self-end max-w-[85%] rounded-2xl border border-accent bg-elevated px-3 py-2 text-[0.8125rem] whitespace-pre-wrap break-words",
23823
24088
  children: [attachments?.length ? /* @__PURE__ */ jsx("div", {
23824
24089
  className: "mb-1.5 flex flex-wrap gap-1",
23825
24090
  children: attachments.map((a) => /* @__PURE__ */ jsx("span", {
23826
- className: "inline-flex max-w-full items-center rounded-md bg-bg/60 px-1.5 py-0.5 text-xs",
24091
+ className: "inline-flex max-w-full items-center rounded-md bg-bg px-1.5 py-0.5 text-xs",
23827
24092
  title: a.name,
23828
24093
  children: a.label ?? a.name
23829
24094
  }, a.name))
@@ -23984,7 +24249,8 @@ function AgentPicker({ agents, defaultAgentId, selection, onChange }) {
23984
24249
  children: /* @__PURE__ */ jsx(Star, {
23985
24250
  size: 16,
23986
24251
  "aria-hidden": true,
23987
- className: n <= shown ? "fill-yellow-400 text-yellow-400" : "text-border"
24252
+ className: n <= shown ? "fill-current" : "text-border",
24253
+ style: n <= shown ? { color: toneVar("yellow") } : void 0
23988
24254
  })
23989
24255
  }) }, `${r.agentId}/${r.presetId}`);
23990
24256
  })
@@ -24177,7 +24443,7 @@ function ChatComposer({ streaming, onSend, onStop, attachments, uploading, uploa
24177
24443
  }
24178
24444
  //#endregion
24179
24445
  //#region src/ui/ChatThreadList.tsx
24180
- function ChatThreadList({ threads, activeId, onOpen, onNew, className = "w-[220px] shrink-0" }) {
24446
+ function ChatThreadList({ threads, activeId, onOpen, onNew, onTogglePrivate, className = "w-[220px] shrink-0" }) {
24181
24447
  const { t } = useTranslation();
24182
24448
  return /* @__PURE__ */ jsxs("aside", {
24183
24449
  "aria-label": t("webchat.threads"),
@@ -24192,11 +24458,36 @@ function ChatThreadList({ threads, activeId, onOpen, onNew, className = "w-[220p
24192
24458
  ]
24193
24459
  }), /* @__PURE__ */ jsx("div", {
24194
24460
  className: "flex-1 overflow-y-auto",
24195
- children: threads.map((th) => /* @__PURE__ */ jsx("button", {
24196
- onClick: () => onOpen(th.convId),
24197
- className: `block w-full truncate px-3 py-2 text-left text-sm hover:bg-elevated ${th.convId === activeId ? "bg-elevated font-medium" : ""}`,
24198
- children: th.title || t("webchat.newConversation")
24199
- }, th.convId))
24461
+ children: threads.map((th) => {
24462
+ const active = th.convId === activeId;
24463
+ return /* @__PURE__ */ jsxs("div", {
24464
+ className: `flex items-center gap-1 pl-1 pr-2 ${active ? "bg-elevated" : "hover:bg-elevated"}`,
24465
+ children: [/* @__PURE__ */ jsxs("button", {
24466
+ onClick: () => onOpen(th.convId),
24467
+ className: `min-w-0 flex-1 py-2 pl-2 text-left text-sm ${active ? "font-medium" : ""}`,
24468
+ children: [/* @__PURE__ */ jsx("span", {
24469
+ className: "block truncate",
24470
+ children: th.title || t("webchat.newConversation")
24471
+ }), (th.mine || th.ownerName) && /* @__PURE__ */ jsx("span", {
24472
+ className: "block truncate text-xs text-muted",
24473
+ children: th.mine ? t("webchat.you") : th.ownerName
24474
+ })]
24475
+ }), th.mine && /* @__PURE__ */ jsx("button", {
24476
+ type: "button",
24477
+ "aria-label": th.private ? t("webchat.makePublic") : t("webchat.makePrivate"),
24478
+ title: th.private ? t("webchat.makePublic") : t("webchat.makePrivate"),
24479
+ onClick: (e) => {
24480
+ e.stopPropagation();
24481
+ onTogglePrivate(th.convId, !th.private);
24482
+ },
24483
+ className: "shrink-0 rounded p-1 text-muted hover:bg-surface hover:text-text focus-visible:outline-2 focus-visible:outline-accent",
24484
+ children: /* @__PURE__ */ jsx(Icon, {
24485
+ name: th.private ? "lucide:shield" : "lucide:globe",
24486
+ size: 14
24487
+ })
24488
+ })]
24489
+ }, th.convId);
24490
+ })
24200
24491
  })]
24201
24492
  });
24202
24493
  }
@@ -24271,7 +24562,7 @@ function ChatDropSurface({ children, onFiles, className }) {
24271
24562
  "data-testid": "chat-drop-overlay",
24272
24563
  className: "pointer-events-none absolute inset-0 z-50 grid place-items-center bg-surface/95 p-6 text-center backdrop-blur-sm",
24273
24564
  children: /* @__PURE__ */ jsxs("div", {
24274
- className: "flex h-full w-full flex-col items-center justify-center rounded-2xl border-2 border-dashed border-accent bg-accent/10 text-accent",
24565
+ className: "flex h-full w-full flex-col items-center justify-center rounded-2xl border-2 border-dashed border-accent text-accent",
24275
24566
  children: [
24276
24567
  /* @__PURE__ */ jsx(Upload, { size: 34 }),
24277
24568
  /* @__PURE__ */ jsx("span", {
@@ -24316,7 +24607,7 @@ function stepMode(mode, wide, delta) {
24316
24607
  function ChatWidget() {
24317
24608
  const { t } = useTranslation();
24318
24609
  const chat = useChat();
24319
- const page = usePageContext();
24610
+ const readPage = usePageContext();
24320
24611
  const [mode, setMode] = useState("bubble");
24321
24612
  const [threadsOpen, setThreadsOpen] = useState(false);
24322
24613
  const [attachments, setAttachments] = useState([]);
@@ -24413,6 +24704,7 @@ function ChatWidget() {
24413
24704
  if (retryFiles.length) pickFiles(retryFiles);
24414
24705
  }, [pickFiles, retryFiles]);
24415
24706
  const sendTurn = useCallback((text, refs) => {
24707
+ const page = readPage();
24416
24708
  const accepted = refs.length ? chat.send(text, page, refs) : chat.send(text, page);
24417
24709
  if (accepted) {
24418
24710
  setAttachments([]);
@@ -24420,7 +24712,7 @@ function ChatWidget() {
24420
24712
  setRetryFiles([]);
24421
24713
  }
24422
24714
  return accepted;
24423
- }, [chat, page]);
24715
+ }, [chat, readPage]);
24424
24716
  const hasConversation = chat.messages.length > 0 || chat.pending !== null;
24425
24717
  const header = /* @__PURE__ */ jsxs("div", {
24426
24718
  className: "flex shrink-0 items-center justify-between border-b border-border px-3 py-2",
@@ -24504,7 +24796,7 @@ function ChatWidget() {
24504
24796
  if (mode === "bubble") return /* @__PURE__ */ jsx("button", {
24505
24797
  "aria-label": t("webchat.open"),
24506
24798
  onClick: () => setMode(restingMode(wide)),
24507
- className: "fixed bottom-20 right-4 z-40 grid h-14 w-14 place-items-center rounded-full bg-accent text-white shadow-lg nav:bottom-4",
24799
+ className: "fixed bottom-20 right-4 z-40 grid h-14 w-14 place-items-center rounded-full bg-accent text-on-accent ring-1 ring-border2 shadow-lg nav:bottom-4",
24508
24800
  children: /* @__PURE__ */ jsx(MessageCircle, { size: 24 })
24509
24801
  });
24510
24802
  if (mode === "panel") return /* @__PURE__ */ jsxs(ChatDropSurface, {
@@ -24541,6 +24833,7 @@ function ChatWidget() {
24541
24833
  setThreadsOpen(false);
24542
24834
  },
24543
24835
  onNew: startNew,
24836
+ onTogglePrivate: chat.togglePrivate,
24544
24837
  className: wide ? "w-[220px] shrink-0" : "w-full"
24545
24838
  }), (wide || !threadsOpen) && conversation]
24546
24839
  })]