@coffer-org/plugin-webchat 7.1.0 → 7.3.0

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,6 +1,6 @@
1
1
  import * as __fedReact from "react";
2
- import { Dialog, DialogContent, Icon, MarkdownView, toneVar, uploadFile, useMinWidth } from "@coffer-org/web-ui";
3
- import { createContext, createElement, forwardRef, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
2
+ import { Button, Dialog, DialogContent, Icon, MarkdownView, fileSizeLabel, recordGone, refreshData, uploadFile, useMinWidth } from "@coffer-org/web-ui";
3
+ import { createContext, createElement, forwardRef, memo, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
4
4
  import { useLocation } from "react-router-dom";
5
5
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
6
  //#region \0rolldown/runtime.js
@@ -496,14 +496,9 @@ var ChevronDown = createLucideIcon("chevron-down", [["path", {
496
496
  * This source code is licensed under the ISC license.
497
497
  * See the LICENSE file in the root directory of this source tree.
498
498
  */
499
- var Clock = createLucideIcon("clock", [["circle", {
500
- cx: "12",
501
- cy: "12",
502
- r: "10",
503
- key: "1mglay"
504
- }], ["path", {
505
- d: "M12 6v6l4 2",
506
- key: "mmk7yg"
499
+ var ChevronRight = createLucideIcon("chevron-right", [["path", {
500
+ d: "m9 18 6-6-6-6",
501
+ key: "mthhwq"
507
502
  }]]);
508
503
  /**
509
504
  * @license lucide-react v1.24.0 - ISC
@@ -511,6 +506,162 @@ var Clock = createLucideIcon("clock", [["circle", {
511
506
  * This source code is licensed under the ISC license.
512
507
  * See the LICENSE file in the root directory of this source tree.
513
508
  */
509
+ var FileArchive = createLucideIcon("file-archive", [
510
+ ["path", {
511
+ d: "M13.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v11.5",
512
+ key: "4pqfef"
513
+ }],
514
+ ["path", {
515
+ d: "M14 2v5a1 1 0 0 0 1 1h5",
516
+ key: "wfsgrz"
517
+ }],
518
+ ["path", {
519
+ d: "M8 12v-1",
520
+ key: "1ej8lb"
521
+ }],
522
+ ["path", {
523
+ d: "M8 18v-2",
524
+ key: "qcmpov"
525
+ }],
526
+ ["path", {
527
+ d: "M8 7V6",
528
+ key: "1nbb54"
529
+ }],
530
+ ["circle", {
531
+ cx: "8",
532
+ cy: "20",
533
+ r: "2",
534
+ key: "ckkr5m"
535
+ }]
536
+ ]);
537
+ /**
538
+ * @license lucide-react v1.24.0 - ISC
539
+ *
540
+ * This source code is licensed under the ISC license.
541
+ * See the LICENSE file in the root directory of this source tree.
542
+ */
543
+ var FileCode = createLucideIcon("file-code", [
544
+ ["path", {
545
+ d: "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z",
546
+ key: "1oefj6"
547
+ }],
548
+ ["path", {
549
+ d: "M14 2v5a1 1 0 0 0 1 1h5",
550
+ key: "wfsgrz"
551
+ }],
552
+ ["path", {
553
+ d: "M10 12.5 8 15l2 2.5",
554
+ key: "1tg20x"
555
+ }],
556
+ ["path", {
557
+ d: "m14 12.5 2 2.5-2 2.5",
558
+ key: "yinavb"
559
+ }]
560
+ ]);
561
+ /**
562
+ * @license lucide-react v1.24.0 - ISC
563
+ *
564
+ * This source code is licensed under the ISC license.
565
+ * See the LICENSE file in the root directory of this source tree.
566
+ */
567
+ var FileHeadphone = createLucideIcon("file-headphone", [
568
+ ["path", {
569
+ d: "M4 6.835V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2h-.343",
570
+ key: "1vfytu"
571
+ }],
572
+ ["path", {
573
+ d: "M14 2v5a1 1 0 0 0 1 1h5",
574
+ key: "wfsgrz"
575
+ }],
576
+ ["path", {
577
+ d: "M2 19a2 2 0 0 1 4 0v1a2 2 0 0 1-4 0v-4a6 6 0 0 1 12 0v4a2 2 0 0 1-4 0v-1a2 2 0 0 1 4 0",
578
+ key: "1etmh7"
579
+ }]
580
+ ]);
581
+ /**
582
+ * @license lucide-react v1.24.0 - ISC
583
+ *
584
+ * This source code is licensed under the ISC license.
585
+ * See the LICENSE file in the root directory of this source tree.
586
+ */
587
+ var FileImage = createLucideIcon("file-image", [
588
+ ["path", {
589
+ d: "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z",
590
+ key: "1oefj6"
591
+ }],
592
+ ["path", {
593
+ d: "M14 2v5a1 1 0 0 0 1 1h5",
594
+ key: "wfsgrz"
595
+ }],
596
+ ["circle", {
597
+ cx: "10",
598
+ cy: "12",
599
+ r: "2",
600
+ key: "737tya"
601
+ }],
602
+ ["path", {
603
+ d: "m20 17-1.296-1.296a2.41 2.41 0 0 0-3.408 0L9 22",
604
+ key: "wt3hpn"
605
+ }]
606
+ ]);
607
+ /**
608
+ * @license lucide-react v1.24.0 - ISC
609
+ *
610
+ * This source code is licensed under the ISC license.
611
+ * See the LICENSE file in the root directory of this source tree.
612
+ */
613
+ var FilePlay = createLucideIcon("file-play", [
614
+ ["path", {
615
+ d: "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z",
616
+ key: "1oefj6"
617
+ }],
618
+ ["path", {
619
+ d: "M14 2v5a1 1 0 0 0 1 1h5",
620
+ key: "wfsgrz"
621
+ }],
622
+ ["path", {
623
+ d: "M15.033 13.44a.647.647 0 0 1 0 1.12l-4.065 2.352a.645.645 0 0 1-.968-.56v-4.704a.645.645 0 0 1 .967-.56z",
624
+ key: "1tzo1f"
625
+ }]
626
+ ]);
627
+ /**
628
+ * @license lucide-react v1.24.0 - ISC
629
+ *
630
+ * This source code is licensed under the ISC license.
631
+ * See the LICENSE file in the root directory of this source tree.
632
+ */
633
+ var FileSpreadsheet = createLucideIcon("file-spreadsheet", [
634
+ ["path", {
635
+ d: "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z",
636
+ key: "1oefj6"
637
+ }],
638
+ ["path", {
639
+ d: "M14 2v5a1 1 0 0 0 1 1h5",
640
+ key: "wfsgrz"
641
+ }],
642
+ ["path", {
643
+ d: "M8 13h2",
644
+ key: "yr2amv"
645
+ }],
646
+ ["path", {
647
+ d: "M14 13h2",
648
+ key: "un5t4a"
649
+ }],
650
+ ["path", {
651
+ d: "M8 17h2",
652
+ key: "2yhykz"
653
+ }],
654
+ ["path", {
655
+ d: "M14 17h2",
656
+ key: "10kma7"
657
+ }]
658
+ ]);
659
+ /**
660
+ * @license lucide-react v1.24.0 - ISC
661
+ *
662
+ * This source code is licensed under the ISC license.
663
+ * See the LICENSE file in the root directory of this source tree.
664
+ */
514
665
  var FileText = createLucideIcon("file-text", [
515
666
  ["path", {
516
667
  d: "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z",
@@ -615,6 +766,19 @@ var MessageCircle = createLucideIcon("message-circle", [["path", {
615
766
  * This source code is licensed under the ISC license.
616
767
  * See the LICENSE file in the root directory of this source tree.
617
768
  */
769
+ var MessagesSquare = createLucideIcon("messages-square", [["path", {
770
+ d: "M16 10a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 14.286V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z",
771
+ key: "1n2ejm"
772
+ }], ["path", {
773
+ d: "M20 9a2 2 0 0 1 2 2v10.286a.71.71 0 0 1-1.212.502l-2.202-2.202A2 2 0 0 0 17.172 19H10a2 2 0 0 1-2-2v-1",
774
+ key: "1qfcsi"
775
+ }]]);
776
+ /**
777
+ * @license lucide-react v1.24.0 - ISC
778
+ *
779
+ * This source code is licensed under the ISC license.
780
+ * See the LICENSE file in the root directory of this source tree.
781
+ */
618
782
  var Minimize2 = createLucideIcon("minimize-2", [
619
783
  ["path", {
620
784
  d: "m14 10 7-7",
@@ -702,16 +866,6 @@ var Square = createLucideIcon("square", [["rect", {
702
866
  * This source code is licensed under the ISC license.
703
867
  * See the LICENSE file in the root directory of this source tree.
704
868
  */
705
- var Star = createLucideIcon("star", [["path", {
706
- d: "M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z",
707
- key: "r04s7s"
708
- }]]);
709
- /**
710
- * @license lucide-react v1.24.0 - ISC
711
- *
712
- * This source code is licensed under the ISC license.
713
- * See the LICENSE file in the root directory of this source tree.
714
- */
715
869
  var Upload = createLucideIcon("upload", [
716
870
  ["path", {
717
871
  d: "M12 3v12",
@@ -897,6 +1051,12 @@ async function openChannel(convId, handlers, signal) {
897
1051
  parentMsgId: String(data["parentMsgId"] ?? ""),
898
1052
  suggestions: Array.isArray(data["suggestions"]) ? data["suggestions"] : null
899
1053
  });
1054
+ else if (frame.event === "refresh") handlers.onRefresh({
1055
+ ...typeof data["library"] === "string" ? { library: data["library"] } : {},
1056
+ ...typeof data["shelf"] === "string" ? { shelf: data["shelf"] } : {},
1057
+ ...typeof data["id"] === "string" ? { id: data["id"] } : {},
1058
+ ...data["gone"] === true ? { gone: true } : {}
1059
+ });
900
1060
  else if (frame.event === "error") handlers.onError({
901
1061
  message: data["message"] ?? null,
902
1062
  parentMsgId: typeof data["parentMsgId"] === "string" ? data["parentMsgId"] : null
@@ -1091,6 +1251,11 @@ function useChat() {
1091
1251
  kind: "done",
1092
1252
  data
1093
1253
  }),
1254
+ onRefresh: ({ gone, ...scope }) => {
1255
+ if (cancelled) return;
1256
+ if (gone) recordGone(scope);
1257
+ else refreshData(scope);
1258
+ },
1094
1259
  onError: (data) => dispatch({
1095
1260
  kind: "error",
1096
1261
  data
@@ -1376,6 +1541,149 @@ function ChatMarkdown({ text, className }) {
1376
1541
  });
1377
1542
  }
1378
1543
  //#endregion
1544
+ //#region src/ui/ChatAttachments.tsx
1545
+ /**
1546
+ * The ONE presentation of a chat attachment — the files staged in the composer and the files a
1547
+ * sent turn carries are drawn by this component, so the two cannot drift apart.
1548
+ *
1549
+ * Three decisions are worth stating, because each replaces something that was there before:
1550
+ *
1551
+ * - **A file is a ROW, not a word.** The bubble used to print `a.label ?? a.name` as bare text
1552
+ * in a wrapping flex, which for eight uploads meant eight lines of 40-character storage names
1553
+ * running to the bubble's edge and clipping mid-name. A row states the three facts a file has
1554
+ * — what kind it is, what it is called, how big it is — and states them in the same places
1555
+ * every time, which is what makes a stack of them scannable.
1556
+ * - **The name breaks in the MIDDLE, not at the end.** The extension is the most informative
1557
+ * part of a long filename and the part a tail-truncation always eats. The stem takes the
1558
+ * truncation (`truncate`), the extension is pinned beside it (`shrink-0`), so `…_2026.pdf`
1559
+ * keeps saying `.pdf` at any width. CSS does this, not a character budget — the same row
1560
+ * works in a 380px panel and in the 1040px modal without measuring anything.
1561
+ * - **Past two files the group COLLAPSES.** A turn carrying eight PDFs is a turn whose text a
1562
+ * reader still has to reach; the attachments are its luggage, not its content. Two rows say
1563
+ * what was sent, `+N` says how much more there is, and one click shows the rest.
1564
+ *
1565
+ * The rows are inert on purpose: an attachment names itself and does nothing else. Opening one
1566
+ * would be a second file-viewing surface beside the record page's, and the chat is not where a
1567
+ * file is read (decided 2026-09-15).
1568
+ */
1569
+ /** How many rows stand before the rest fold away. Two is what a bubble can carry without the
1570
+ * luggage outweighing the message — see the component's note. */
1571
+ var COLLAPSE_AFTER = 2;
1572
+ /** Extension families, in the order a name is tested against them. */
1573
+ var BY_EXT = [
1574
+ [/\.(png|jpe?g|gif|webp|avif|svg|bmp|heic|heif)$/i, FileImage],
1575
+ [/\.(mp3|wav|ogg|oga|flac|m4a|aac|opus)$/i, FileHeadphone],
1576
+ [/\.(mp4|mov|mkv|webm|avi|m4v)$/i, FilePlay],
1577
+ [/\.(zip|rar|7z|tar|gz|tgz|bz2|xz)$/i, FileArchive],
1578
+ [/\.(csv|tsv|xlsx?|ods|numbers)$/i, FileSpreadsheet],
1579
+ [/\.(json|xml|ya?ml|toml|ini|js|jsx|ts|tsx|css|html?|sh|py|sql)$/i, FileCode]
1580
+ ];
1581
+ /**
1582
+ * What KIND of file this is, at a glance. The mime type is the server's answer and is trusted
1583
+ * first; an upload that arrives with none (or with the `application/octet-stream` a browser
1584
+ * falls back to) is read from its extension instead, which is the only fact left.
1585
+ */
1586
+ function attachmentIcon(a) {
1587
+ const mime = a.mime?.toLowerCase().split(";", 1)[0] ?? "";
1588
+ if (mime.startsWith("image/")) return FileImage;
1589
+ if (mime.startsWith("audio/")) return FileHeadphone;
1590
+ if (mime.startsWith("video/")) return FilePlay;
1591
+ if (mime === "text/csv" || mime.includes("spreadsheet") || mime.includes("excel")) return FileSpreadsheet;
1592
+ if (mime === "application/json" || mime === "application/xml" || mime.startsWith("text/x")) return FileCode;
1593
+ if (mime === "application/zip" || mime.includes("compressed") || mime.includes("tar")) return FileArchive;
1594
+ const name = a.label ?? a.name;
1595
+ for (const [re, icon] of BY_EXT) if (re.test(name)) return icon;
1596
+ return FileText;
1597
+ }
1598
+ /**
1599
+ * Split a filename into the part that may be truncated and the part that may not. A leading dot
1600
+ * (`.gitignore`) is not an extension, and neither is a "dot" eleven characters from the end of a
1601
+ * sentence-like name — an extension is short, so anything longer is left in the stem where the
1602
+ * truncation can reach it.
1603
+ */
1604
+ function splitFileName(name) {
1605
+ const dot = name.lastIndexOf(".");
1606
+ if (dot <= 0 || name.length - dot > 11) return {
1607
+ stem: name,
1608
+ ext: ""
1609
+ };
1610
+ return {
1611
+ stem: name.slice(0, dot),
1612
+ ext: name.slice(dot)
1613
+ };
1614
+ }
1615
+ function AttachmentRow({ a, onRemove }) {
1616
+ const { t } = useTranslation();
1617
+ const name = a.label ?? a.name;
1618
+ const { stem, ext } = splitFileName(name);
1619
+ const size = fileSizeLabel(a.size);
1620
+ return /* @__PURE__ */ jsxs("li", {
1621
+ className: "flex min-w-0 items-center gap-2 rounded-[calc(var(--radius-md)+1px)] border border-border bg-background py-1 pr-1.5 pl-2",
1622
+ children: [
1623
+ /* @__PURE__ */ jsx(attachmentIcon(a), {
1624
+ size: 14,
1625
+ "aria-hidden": true,
1626
+ className: "shrink-0 text-muted-foreground"
1627
+ }),
1628
+ /* @__PURE__ */ jsxs("span", {
1629
+ className: "flex min-w-0 flex-1 text-xs",
1630
+ title: name,
1631
+ children: [/* @__PURE__ */ jsx("span", {
1632
+ className: "truncate",
1633
+ children: stem
1634
+ }), ext && /* @__PURE__ */ jsx("span", {
1635
+ className: "shrink-0 text-muted-foreground",
1636
+ children: ext
1637
+ })]
1638
+ }),
1639
+ size && /* @__PURE__ */ jsx("span", {
1640
+ className: "shrink-0 text-[0.6875rem] tabular-nums text-muted-foreground",
1641
+ children: size
1642
+ }),
1643
+ onRemove && /* @__PURE__ */ jsx("button", {
1644
+ type: "button",
1645
+ "aria-label": t("webchat.removeAttachment", { name }),
1646
+ title: t("webchat.removeAttachment", { name }),
1647
+ className: "shrink-0 rounded p-0.5 text-muted-foreground hover:text-destructive focus-visible:outline-2 focus-visible:outline-ring",
1648
+ onClick: onRemove,
1649
+ children: /* @__PURE__ */ jsx(X, { size: 13 })
1650
+ })
1651
+ ]
1652
+ });
1653
+ }
1654
+ function ChatAttachments({ items, onRemove, className = "" }) {
1655
+ const { t } = useTranslation();
1656
+ const [expanded, setExpanded] = useState(false);
1657
+ const listId = useId();
1658
+ if (!items.length) return null;
1659
+ const hidden = expanded ? 0 : Math.max(0, items.length - COLLAPSE_AFTER);
1660
+ const shown = hidden ? items.slice(0, COLLAPSE_AFTER) : items;
1661
+ return /* @__PURE__ */ jsxs("div", {
1662
+ className: `flex flex-col gap-1 ${className}`,
1663
+ "data-chat-attachments": true,
1664
+ children: [/* @__PURE__ */ jsx("ul", {
1665
+ id: listId,
1666
+ "aria-label": t("webchat.attachments"),
1667
+ className: "flex flex-col gap-1",
1668
+ children: shown.map((a, i) => /* @__PURE__ */ jsx(AttachmentRow, {
1669
+ a,
1670
+ ...onRemove ? { onRemove: () => onRemove(i) } : {}
1671
+ }, `${a.name}-${i}`))
1672
+ }), items.length > COLLAPSE_AFTER && /* @__PURE__ */ jsxs("button", {
1673
+ type: "button",
1674
+ "aria-expanded": expanded,
1675
+ "aria-controls": listId,
1676
+ className: "inline-flex items-center gap-1 self-start rounded px-1 py-0.5 text-xs text-muted-foreground hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring",
1677
+ onClick: () => setExpanded((v) => !v),
1678
+ children: [/* @__PURE__ */ jsx(ChevronDown, {
1679
+ size: 12,
1680
+ "aria-hidden": true,
1681
+ className: expanded ? "rotate-180 transition-transform" : "transition-transform"
1682
+ }), expanded ? t("webchat.fewerFiles") : t("webchat.moreFiles", { count: hidden })]
1683
+ })]
1684
+ });
1685
+ }
1686
+ //#endregion
1379
1687
  //#region src/ui/ChatMessages.tsx
1380
1688
  function ChatMessages({ messages, pending, pendingReasoning, trailing }) {
1381
1689
  const { t } = useTranslation();
@@ -1389,8 +1697,15 @@ function ChatMessages({ messages, pending, pendingReasoning, trailing }) {
1389
1697
  "data-chat-list": true,
1390
1698
  className: "flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-2",
1391
1699
  children: [!messages.length && pending === null && pendingReasoning === null ? /* @__PURE__ */ jsx("div", {
1392
- className: "flex-1 grid place-items-center text-muted-foreground text-sm",
1393
- children: t("webchat.empty")
1700
+ className: "grid flex-1 place-items-center px-6 text-center text-sm text-muted-foreground",
1701
+ children: /* @__PURE__ */ jsxs("span", {
1702
+ className: "flex flex-col items-center gap-2",
1703
+ children: [/* @__PURE__ */ jsx(MessagesSquare, {
1704
+ size: 28,
1705
+ "aria-hidden": true,
1706
+ className: "text-faint"
1707
+ }), t("webchat.empty")]
1708
+ })
1394
1709
  }) : /* @__PURE__ */ jsxs(Fragment, { children: [messages.map((m) => /* @__PURE__ */ jsx(Bubble, {
1395
1710
  role: m.role,
1396
1711
  text: m.text,
@@ -1412,23 +1727,23 @@ function ChatMessages({ messages, pending, pendingReasoning, trailing }) {
1412
1727
  var Bubble = memo(function Bubble({ role, text, reasoning, attachments }) {
1413
1728
  const { t } = useTranslation();
1414
1729
  if (role === "user") return /* @__PURE__ */ jsxs("div", {
1415
- className: "self-end max-w-[85%] rounded-2xl border border-ring bg-muted px-3 py-2 text-[0.8125rem] whitespace-pre-wrap break-words",
1416
- children: [attachments?.length ? /* @__PURE__ */ jsx("div", {
1417
- className: "mb-1.5 flex flex-wrap gap-1",
1418
- children: attachments.map((a) => /* @__PURE__ */ jsx("span", {
1419
- className: "inline-flex max-w-full items-center rounded-md bg-background px-1.5 py-0.5 text-xs",
1420
- title: a.name,
1421
- children: a.label ?? a.name
1422
- }, a.name))
1730
+ className: "max-w-[85%] min-w-0 self-end rounded-2xl rounded-br-sm border border-border bg-accent px-3 py-2 text-[0.8125rem] break-words whitespace-pre-wrap",
1731
+ children: [attachments?.length ? /* @__PURE__ */ jsx(ChatAttachments, {
1732
+ items: attachments,
1733
+ className: `-mx-1 ${text ? "mb-2" : ""}`
1423
1734
  }) : null, text]
1424
1735
  });
1425
1736
  return /* @__PURE__ */ jsxs("div", {
1426
- className: "self-start max-w-[95%] min-w-0 rounded-2xl bg-muted px-3 py-2 text-sm break-words",
1737
+ className: "max-w-[95%] min-w-0 self-start rounded-2xl rounded-bl-sm bg-muted px-3 py-2 text-sm break-words",
1427
1738
  children: [reasoning ? /* @__PURE__ */ jsxs("details", {
1428
- className: "mb-2 rounded-lg border border-border px-2 py-1",
1429
- children: [/* @__PURE__ */ jsx("summary", {
1430
- className: "cursor-pointer select-none text-xs text-muted-foreground",
1431
- children: t("webchat.reasoning")
1739
+ className: "group/reason -mx-1 mb-2 rounded-[calc(var(--radius-md)+1px)] border border-border bg-background px-2 py-1",
1740
+ children: [/* @__PURE__ */ jsxs("summary", {
1741
+ className: "flex cursor-pointer list-none items-center gap-1 select-none text-xs text-muted-foreground hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring [&::-webkit-details-marker]:hidden",
1742
+ children: [/* @__PURE__ */ jsx(ChevronRight, {
1743
+ size: 12,
1744
+ "aria-hidden": true,
1745
+ className: "shrink-0 transition-transform group-open/reason:rotate-90"
1746
+ }), t("webchat.reasoning")]
1432
1747
  }), /* @__PURE__ */ jsx(ChatMarkdown, {
1433
1748
  text: reasoning,
1434
1749
  className: "md-static md-chat md-chat-sub mt-1 text-muted-foreground"
@@ -1441,81 +1756,23 @@ var Bubble = memo(function Bubble({ role, text, reasoning, attachments }) {
1441
1756
  });
1442
1757
  //#endregion
1443
1758
  //#region src/ui/AgentPicker.tsx
1444
- var MAX_STARS = 5;
1445
- /** Rating pair: "3 star, 3 clock". Two digits read at a glance and stay the same
1446
- * width on every row, which a free-text hint ("fastest and cheapest" next to
1447
- * "quick") does not. Both are read the same way — the star is what you get, the
1448
- * clock is what it costs you in waiting — so a heavier model raises both numbers.
1449
- * Falls back to the hint when the agent publishes no ratings. */
1450
- function PresetRating({ quality, time, hint }) {
1451
- const { t } = useTranslation();
1452
- if (quality === void 0 || time === void 0) return hint ? /* @__PURE__ */ jsx("span", {
1453
- className: "shrink-0 text-muted-foreground",
1454
- children: hint
1455
- }) : null;
1456
- const both = quality !== time;
1457
- return /* @__PURE__ */ jsxs("span", {
1458
- className: "flex shrink-0 items-center gap-1.5 text-muted-foreground tabular-nums",
1459
- children: [/* @__PURE__ */ jsxs("span", {
1460
- className: "flex items-center gap-0.5",
1461
- title: t("webchat.quality", { value: quality }),
1462
- children: [
1463
- quality,
1464
- /* @__PURE__ */ jsx(Star, {
1465
- size: 11,
1466
- "aria-hidden": true,
1467
- className: "shrink-0"
1468
- }),
1469
- /* @__PURE__ */ jsx("span", {
1470
- className: "sr-only",
1471
- children: t("webchat.quality", { value: quality })
1472
- })
1473
- ]
1474
- }), both ? /* @__PURE__ */ jsxs("span", {
1475
- className: "flex items-center gap-0.5",
1476
- title: t("webchat.time", { value: time }),
1477
- children: [
1478
- time,
1479
- /* @__PURE__ */ jsx(Clock, {
1480
- size: 11,
1481
- "aria-hidden": true,
1482
- className: "shrink-0"
1483
- }),
1484
- /* @__PURE__ */ jsx("span", {
1485
- className: "sr-only",
1486
- children: t("webchat.time", { value: time })
1487
- })
1488
- ]
1489
- }) : /* @__PURE__ */ jsx("span", {
1490
- className: "sr-only",
1491
- children: t("webchat.time", { value: time })
1492
- })]
1493
- });
1494
- }
1495
1759
  /**
1496
- * The presets of ONE agent, ordered by their level, when they form a complete ladder:
1497
- * a unique `quality` per preset, contiguous from 1, at most five rungs. Only then does a
1498
- * star count address a preset without ambiguity, and only then may the picker stop
1499
- * printing model names. Anything else two agents, a gap in the ladder, a preset with
1500
- * no rating returns null and the picker keeps its list of names.
1760
+ * Flatten the catalog to one list: with a single registered agent the agent level is
1761
+ * noise, and with several the agent name simply prefixes its presets.
1762
+ *
1763
+ * ONE mode, a list of names. Until 2026-09-16 there was a second a row of stars in the
1764
+ * composer, where a preset's `quality` rating addressed it by rung and the model name was
1765
+ * never printed. It is gone, with the ratings it read. A star count can only address a
1766
+ * preset when the presets form a single totally ordered line, and `claude-agent`'s do
1767
+ * not: nothing published ranks `sonnet-high` against `opus-low`, and the cheaper of the
1768
+ * two is not the weaker one. The list that used to be the FALLBACK — for several agents,
1769
+ * a gap in the ladder, a preset with no rating — was always the honest control, and is
1770
+ * now the only one. See
1771
+ * `docs/superpowers/specs/2026-09-16-agent-preset-is-a-model-choice-design.md`.
1501
1772
  */
1502
- function starLadder(rows, multi) {
1503
- if (multi || rows.length < 2 || rows.length > MAX_STARS) return null;
1504
- const byLevel = Array.from({ length: rows.length });
1505
- for (const r of rows) {
1506
- const q = r.quality;
1507
- if (q === void 0 || !Number.isInteger(q) || q < 1 || q > rows.length) return null;
1508
- if (byLevel[q - 1]) return null;
1509
- byLevel[q - 1] = r;
1510
- }
1511
- return byLevel.every((r) => r !== void 0) ? byLevel : null;
1512
- }
1513
- /** Flatten the catalog to one list: with a single registered agent the agent
1514
- * level is noise, and with several the agent name simply prefixes its presets. */
1515
1773
  function AgentPicker({ agents, defaultAgentId, selection, onChange }) {
1516
1774
  const { t } = useTranslation();
1517
1775
  const [open, setOpen] = useState(false);
1518
- const [preview, setPreview] = useState(null);
1519
1776
  const rootRef = useRef(null);
1520
1777
  const triggerRef = useRef(null);
1521
1778
  useEffect(() => {
@@ -1542,48 +1799,10 @@ function AgentPicker({ agents, defaultAgentId, selection, onChange }) {
1542
1799
  presetId: p.id,
1543
1800
  label: multi ? `${a.title} · ${p.title}` : p.title,
1544
1801
  hint: p.hint,
1545
- quality: p.quality,
1546
- time: p.time,
1547
1802
  isDefault: p.default === true
1548
1803
  })));
1549
1804
  if (rows.length === 0) return null;
1550
1805
  const current = rows.find((r) => r.agentId === selection.agentId && r.presetId === selection.presetId) ?? rows.find((r) => r.agentId === defaultAgentId && r.isDefault) ?? rows.find((r) => r.isDefault) ?? rows[0];
1551
- const ladder = starLadder(rows, multi);
1552
- if (ladder) {
1553
- const level = ladder.indexOf(current) + 1;
1554
- const shown = preview ?? level;
1555
- const levelName = (row, n) => `${row.label} · ${t("webchat.level", {
1556
- value: n,
1557
- max: ladder.length
1558
- })}`;
1559
- return /* @__PURE__ */ jsx("ul", {
1560
- "aria-label": t("webchat.agentPicker"),
1561
- className: "flex shrink-0 items-center",
1562
- onPointerLeave: () => setPreview(null),
1563
- children: ladder.map((r, i) => {
1564
- const n = i + 1;
1565
- return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("button", {
1566
- type: "button",
1567
- ...r === current ? { "aria-current": true } : {},
1568
- "aria-label": levelName(r, n),
1569
- className: "flex rounded p-1 focus-visible:outline-2 focus-visible:outline-ring",
1570
- onPointerEnter: () => setPreview(n),
1571
- onFocus: () => setPreview(n),
1572
- onBlur: () => setPreview(null),
1573
- onClick: () => onChange({
1574
- agentId: r.agentId,
1575
- presetId: r.presetId
1576
- }),
1577
- children: /* @__PURE__ */ jsx(Star, {
1578
- size: 16,
1579
- "aria-hidden": true,
1580
- className: n <= shown ? "fill-current" : "text-border",
1581
- style: n <= shown ? { color: toneVar("yellow") } : void 0
1582
- })
1583
- }) }, `${r.agentId}/${r.presetId}`);
1584
- })
1585
- });
1586
- }
1587
1806
  return /* @__PURE__ */ jsxs("div", {
1588
1807
  className: "relative",
1589
1808
  ref: rootRef,
@@ -1596,7 +1815,7 @@ function AgentPicker({ agents, defaultAgentId, selection, onChange }) {
1596
1815
  onClick: () => setOpen((v) => !v),
1597
1816
  children: [/* @__PURE__ */ jsx("span", {
1598
1817
  className: "max-w-40 truncate",
1599
- children: current.label
1818
+ children: current.hint ? `${current.label} · ${current.hint}` : current.label
1600
1819
  }), /* @__PURE__ */ jsx(ChevronDown, {
1601
1820
  size: 12,
1602
1821
  className: "shrink-0"
@@ -1626,11 +1845,10 @@ function AgentPicker({ agents, defaultAgentId, selection, onChange }) {
1626
1845
  className: "min-w-0 flex-1 truncate",
1627
1846
  children: r.label
1628
1847
  }),
1629
- /* @__PURE__ */ jsx(PresetRating, {
1630
- quality: r.quality,
1631
- time: r.time,
1632
- hint: r.hint
1633
- })
1848
+ r.hint ? /* @__PURE__ */ jsx("span", {
1849
+ className: "shrink-0 text-muted-foreground",
1850
+ children: r.hint
1851
+ }) : null
1634
1852
  ]
1635
1853
  }) }, `${r.agentId}/${r.presetId}`);
1636
1854
  })
@@ -1649,29 +1867,12 @@ function ChatComposer({ streaming, onSend, onStop, attachments, uploading, uploa
1649
1867
  return /* @__PURE__ */ jsxs("div", {
1650
1868
  className: "flex shrink-0 flex-col gap-2 border-t border-border p-2",
1651
1869
  children: [(attachments.length > 0 || uploading || uploadError) && /* @__PURE__ */ jsxs("div", {
1652
- className: "flex flex-wrap items-center gap-1.5 px-1",
1653
- "data-chat-attachments": true,
1870
+ className: "flex flex-col gap-1.5 px-1",
1654
1871
  children: [
1655
- attachments.map((a, i) => /* @__PURE__ */ jsxs("span", {
1656
- className: "inline-flex min-w-0 max-w-full items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs",
1657
- children: [
1658
- /* @__PURE__ */ jsx(FileText, {
1659
- size: 13,
1660
- className: "shrink-0 text-muted-foreground"
1661
- }),
1662
- /* @__PURE__ */ jsx("span", {
1663
- className: "max-w-48 truncate",
1664
- children: a.label ?? a.name
1665
- }),
1666
- /* @__PURE__ */ jsx("button", {
1667
- type: "button",
1668
- "aria-label": t("webchat.removeAttachment", { name: a.label ?? a.name }),
1669
- className: "shrink-0 rounded p-0.5 text-muted-foreground hover:text-destructive",
1670
- onClick: () => onRemoveAttachment(i),
1671
- children: /* @__PURE__ */ jsx(X, { size: 13 })
1672
- })
1673
- ]
1674
- }, `${a.name}-${i}`)),
1872
+ /* @__PURE__ */ jsx(ChatAttachments, {
1873
+ items: attachments,
1874
+ onRemove: onRemoveAttachment
1875
+ }),
1675
1876
  uploading && /* @__PURE__ */ jsxs("div", {
1676
1877
  className: "flex min-w-32 flex-1 items-center gap-2",
1677
1878
  "aria-label": t("webchat.uploading"),
@@ -1705,7 +1906,7 @@ function ChatComposer({ streaming, onSend, onStop, attachments, uploading, uploa
1705
1906
  children: t(uploadError)
1706
1907
  }), canRetryUpload && /* @__PURE__ */ jsxs("button", {
1707
1908
  type: "button",
1708
- className: "inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 font-medium text-primary hover:bg-muted",
1909
+ className: "inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 font-medium text-primary hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring",
1709
1910
  onClick: onRetryUpload,
1710
1911
  children: [
1711
1912
  /* @__PURE__ */ jsx(RotateCcw, { size: 12 }),
@@ -1718,7 +1919,7 @@ function ChatComposer({ streaming, onSend, onStop, attachments, uploading, uploa
1718
1919
  }), /* @__PURE__ */ jsxs("div", {
1719
1920
  className: "flex flex-col gap-1",
1720
1921
  children: [/* @__PURE__ */ jsx("textarea", {
1721
- className: "w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm max-h-32",
1922
+ className: "max-h-32 w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm transition-colors outline-none placeholder:text-faint focus:border-ring focus:ring-2 focus:ring-ring",
1722
1923
  rows: 1,
1723
1924
  value: text,
1724
1925
  placeholder: t("webchat.placeholder"),
@@ -1733,12 +1934,16 @@ function ChatComposer({ streaming, onSend, onStop, attachments, uploading, uploa
1733
1934
  className: "flex items-center gap-1",
1734
1935
  children: [
1735
1936
  attachmentsAllowed && /* @__PURE__ */ jsxs("label", {
1736
- "aria-label": t("webchat.attach"),
1737
- className: `shrink-0 cursor-pointer rounded-lg p-1.5 text-muted-foreground hover:bg-muted hover:text-primary ${uploading ? "pointer-events-none opacity-60" : ""}`,
1738
- children: [/* @__PURE__ */ jsx(Paperclip, { size: 18 }), /* @__PURE__ */ jsx("input", {
1937
+ title: t("webchat.attach"),
1938
+ className: `flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-within:ring-2 focus-within:ring-ring ${uploading ? "pointer-events-none opacity-60" : ""}`,
1939
+ children: [/* @__PURE__ */ jsx(Paperclip, {
1940
+ size: 18,
1941
+ "aria-hidden": true
1942
+ }), /* @__PURE__ */ jsx("input", {
1739
1943
  type: "file",
1740
1944
  multiple: true,
1741
- className: "hidden",
1945
+ "aria-label": t("webchat.attach"),
1946
+ className: "sr-only",
1742
1947
  disabled: uploading,
1743
1948
  onChange: (e) => {
1744
1949
  if (e.target.files?.length) onPickFiles(e.target.files);
@@ -1753,14 +1958,23 @@ function ChatComposer({ streaming, onSend, onStop, attachments, uploading, uploa
1753
1958
  onChange: onChooseAgent
1754
1959
  }),
1755
1960
  /* @__PURE__ */ jsx("div", { className: "flex-1" }),
1756
- streaming ? /* @__PURE__ */ jsx("button", {
1961
+ streaming ? /* @__PURE__ */ jsx(Button, {
1962
+ type: "button",
1963
+ variant: "ghost",
1964
+ size: "iconSm",
1757
1965
  "aria-label": t("webchat.stop"),
1758
- className: "shrink-0 rounded-lg p-1.5 hover:bg-muted",
1966
+ title: t("webchat.stop"),
1967
+ className: "shrink-0",
1759
1968
  onClick: onStop,
1760
1969
  children: /* @__PURE__ */ jsx(Square, { size: 18 })
1761
- }) : /* @__PURE__ */ jsx("button", {
1970
+ }) : /* @__PURE__ */ jsx(Button, {
1971
+ type: "button",
1972
+ variant: "ghost",
1973
+ size: "iconSm",
1762
1974
  "aria-label": t("webchat.send"),
1763
- className: "shrink-0 rounded-lg p-1.5 hover:bg-muted",
1975
+ title: t("webchat.send"),
1976
+ disabled: !text.trim(),
1977
+ className: "shrink-0 hover:text-primary",
1764
1978
  onClick: submit,
1765
1979
  children: /* @__PURE__ */ jsx(Send, { size: 18 })
1766
1980
  })
@@ -1775,12 +1989,15 @@ function ChatThreadList({ threads, activeId, onOpen, onNew, onTogglePrivate, cla
1775
1989
  const { t } = useTranslation();
1776
1990
  return /* @__PURE__ */ jsxs("aside", {
1777
1991
  "aria-label": t("webchat.threads"),
1778
- className: `${className} border-r border-border flex flex-col`,
1992
+ className: `${className} flex flex-col border-r border-border`,
1779
1993
  children: [/* @__PURE__ */ jsxs("button", {
1780
- className: "flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted",
1994
+ className: "flex items-center gap-2 border-b border-border px-3 py-2 text-sm font-medium text-primary hover:bg-muted focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring",
1781
1995
  onClick: onNew,
1782
1996
  children: [
1783
- /* @__PURE__ */ jsx(Plus, { size: 16 }),
1997
+ /* @__PURE__ */ jsx(Plus, {
1998
+ size: 16,
1999
+ "aria-hidden": true
2000
+ }),
1784
2001
  " ",
1785
2002
  t("webchat.newConversation")
1786
2003
  ]
@@ -1789,10 +2006,10 @@ function ChatThreadList({ threads, activeId, onOpen, onNew, onTogglePrivate, cla
1789
2006
  children: threads.map((th) => {
1790
2007
  const active = th.convId === activeId;
1791
2008
  return /* @__PURE__ */ jsxs("div", {
1792
- className: `flex items-center gap-1 pl-1 pr-2 ${active ? "bg-muted" : "hover:bg-muted"}`,
2009
+ className: `flex items-center gap-1 border-l-2 pr-2 pl-1 ${active ? "border-ring bg-accent" : "border-transparent hover:bg-muted"}`,
1793
2010
  children: [/* @__PURE__ */ jsxs("button", {
1794
2011
  onClick: () => onOpen(th.convId),
1795
- className: `min-w-0 flex-1 py-2 pl-2 text-left text-sm ${active ? "font-medium" : ""}`,
2012
+ className: `min-w-0 flex-1 rounded-sm py-2 pl-2 text-left text-sm focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring ${active ? "font-medium" : ""}`,
1796
2013
  children: [/* @__PURE__ */ jsx("span", {
1797
2014
  className: "block truncate",
1798
2015
  children: th.title || t("webchat.newConversation")
@@ -2047,36 +2264,49 @@ function ChatWidget() {
2047
2264
  const hasConversation = chat.messages.length > 0 || chat.pending !== null;
2048
2265
  const activeTitle = chat.threads.find((th) => th.convId === chat.convId)?.title;
2049
2266
  const header = /* @__PURE__ */ jsxs("div", {
2050
- className: "flex shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-2",
2267
+ className: "flex shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-1.5",
2051
2268
  children: [/* @__PURE__ */ jsx("span", {
2052
2269
  className: "min-w-0 truncate text-sm font-medium",
2053
2270
  children: activeTitle || t("webchat.title")
2054
2271
  }), /* @__PURE__ */ jsxs("div", {
2055
- className: "flex shrink-0 items-center gap-1",
2272
+ className: "-mr-1 flex shrink-0 items-center gap-0.5",
2056
2273
  children: [
2057
- !hasSidebar && /* @__PURE__ */ jsx("button", {
2274
+ hasConversation && /* @__PURE__ */ jsx(Button, {
2275
+ type: "button",
2276
+ variant: "ghost",
2277
+ size: "iconSm",
2278
+ "aria-label": t("webchat.newConversation"),
2279
+ title: t("webchat.newConversation"),
2280
+ onClick: startNew,
2281
+ children: /* @__PURE__ */ jsx(Plus, { size: 16 })
2282
+ }),
2283
+ !hasSidebar && /* @__PURE__ */ jsx(Button, {
2284
+ type: "button",
2285
+ variant: "ghost",
2286
+ size: "iconSm",
2058
2287
  "aria-label": t("webchat.threads"),
2288
+ title: t("webchat.threads"),
2059
2289
  "aria-expanded": threadsOpen,
2060
- className: "rounded p-1 hover:bg-muted",
2290
+ className: threadsOpen ? "bg-muted text-foreground" : "",
2061
2291
  onClick: () => setThreadsOpen((v) => !v),
2062
2292
  children: /* @__PURE__ */ jsx(List, { size: 16 })
2063
2293
  }),
2064
- hasConversation && /* @__PURE__ */ jsx("button", {
2065
- "aria-label": t("webchat.newConversation"),
2066
- className: "rounded p-1 hover:bg-muted",
2067
- onClick: startNew,
2068
- children: /* @__PURE__ */ jsx(Plus, { size: 16 })
2069
- }),
2070
- chatModes(wide).includes("panel") && /* @__PURE__ */ jsx("button", {
2294
+ chatModes(wide).includes("panel") && /* @__PURE__ */ jsx(Button, {
2071
2295
  ref: expandBtnRef,
2296
+ type: "button",
2297
+ variant: "ghost",
2298
+ size: "iconSm",
2072
2299
  "aria-label": mode === "modal" ? t("webchat.collapse") : t("webchat.expand"),
2073
- className: "rounded p-1 hover:bg-muted",
2300
+ title: mode === "modal" ? t("webchat.collapse") : t("webchat.expand"),
2074
2301
  onClick: () => setMode(stepMode(mode, wide, mode === "modal" ? -1 : 1)),
2075
2302
  children: mode === "modal" ? /* @__PURE__ */ jsx(Minimize2, { size: 16 }) : /* @__PURE__ */ jsx(Maximize2, { size: 16 })
2076
2303
  }),
2077
- /* @__PURE__ */ jsx("button", {
2304
+ /* @__PURE__ */ jsx(Button, {
2305
+ type: "button",
2306
+ variant: "ghost",
2307
+ size: "iconSm",
2078
2308
  "aria-label": t("webchat.close"),
2079
- className: "rounded p-1 hover:bg-muted",
2309
+ title: t("webchat.close"),
2080
2310
  onClick: () => setMode("bubble"),
2081
2311
  children: /* @__PURE__ */ jsx(X, { size: 16 })
2082
2312
  })
@@ -1,6 +1,14 @@
1
1
  import { frontendInstructions } from '@coffer-org/server/frontend-agent';
2
2
  import { NEUTRAL_FORMAT } from '@coffer-org/markdown';
3
+ const LIVE_PAGE = [
4
+ 'You are answering inside the app, in a panel over a page the person is reading.',
5
+ 'That page does not reload itself. After you create, update, delete or restore a record,',
6
+ 'call `refresh_open_page` — naming the record when you know which one — so what they see matches',
7
+ 'what you just told them. If you DELETED the record, pass `deleted: true` with it, so the page',
8
+ 'showing it returns to the list instead of sitting on something that is no longer there.',
9
+ 'Skip the call when you only read.',
10
+ ].join(' ');
3
11
  export async function webChannelSystem() {
4
12
  const site = await frontendInstructions('');
5
- return [NEUTRAL_FORMAT, site].filter(Boolean).join('\n\n');
13
+ return [NEUTRAL_FORMAT, site, LIVE_PAGE].filter(Boolean).join('\n\n');
6
14
  }
@@ -1,6 +1,7 @@
1
1
  import type { PluginHooks } from '@coffer-org/server/plugin-hooks';
2
2
  export type { ThreadSummary, HistoryMsg } from './actions.ts';
3
3
  export { makeStreamingConnector } from './connector.ts';
4
+ export { makeRefreshTool } from './refresh-tool.ts';
4
5
  export { getConversation as getSelection, setConversation as setSelection, } from '@coffer-org/server/conversation-store';
5
6
  export { sendAction, pageContext } from './send.ts';
6
7
  export declare const serverHooks: PluginHooks;
@@ -6,6 +6,7 @@ import { initFanout, teardownFanout } from "./channel-registry.js";
6
6
  const THREAD_TTL_MS = Number(process.env['WEBCHAT_THREAD_TTL_MS'] ?? 30 * 86_400_000);
7
7
  let unregisterConnector;
8
8
  export { makeStreamingConnector } from "./connector.js";
9
+ export { makeRefreshTool } from "./refresh-tool.js";
9
10
  export { getConversation as getSelection, setConversation as setSelection, } from '@coffer-org/server/conversation-store';
10
11
  export { sendAction, pageContext } from "./send.js";
11
12
  export const serverHooks = {
@@ -0,0 +1,2 @@
1
+ import type { AgentToolProvider } from '@coffer-org/server/orchestrator';
2
+ export declare function makeRefreshTool(conversationId: string): AgentToolProvider;
@@ -0,0 +1,49 @@
1
+ import { getLogger } from '@coffer-org/sdk/logger';
2
+ import { broadcast, subscriberCount } from "./channel-registry.js";
3
+ const log = getLogger('webchat');
4
+ function str(value) {
5
+ return typeof value === 'string' && value.trim() ? value.trim() : '';
6
+ }
7
+ export function makeRefreshTool(conversationId) {
8
+ return {
9
+ tools: [
10
+ {
11
+ name: 'mcp__coffer__refresh_open_page',
12
+ description: 'Reload the data on the page the user currently has open in the browser. ' +
13
+ 'Call it right after you create, update, delete or restore a record, so what they see ' +
14
+ 'matches what you just changed — otherwise the page keeps showing the values it loaded ' +
15
+ 'before your edit. Name the record you touched when you know it. ' +
16
+ 'When you DELETED or trashed the record, pass deleted: true with its library, shelf and ' +
17
+ 'id: the page showing it then returns to the list instead of sitting on a record that is ' +
18
+ 'no longer there. ' +
19
+ 'It is a display-only call: it changes no data, costs the user nothing, and is safe to ' +
20
+ 'repeat. Do not call it when you only READ records.',
21
+ inputSchema: {
22
+ library: { type: 'string', description: 'Library of the record you changed, e.g. health' },
23
+ shelf: { type: 'string', description: 'Shelf of the record you changed, e.g. drug' },
24
+ id: { type: 'string', description: 'Id of the record you changed' },
25
+ deleted: {
26
+ type: 'boolean',
27
+ description: 'True when you deleted or trashed that record, so the page leaves it',
28
+ },
29
+ },
30
+ handler: async (args) => {
31
+ const library = str(args['library']);
32
+ const shelf = str(args['shelf']);
33
+ const id = str(args['id']);
34
+ const gone = args['deleted'] === true && Boolean(id);
35
+ const scope = {
36
+ ...(library ? { library } : {}),
37
+ ...(shelf ? { shelf } : {}),
38
+ ...(id ? { id } : {}),
39
+ ...(gone ? { gone: true } : {}),
40
+ };
41
+ broadcast(conversationId, 'refresh', scope);
42
+ const viewers = subscriberCount(conversationId);
43
+ log.info(`refresh_open_page ${JSON.stringify(scope)} → ${viewers} viewer(s) on ${conversationId}`);
44
+ return { requested: true, gone, viewers };
45
+ },
46
+ },
47
+ ],
48
+ };
49
+ }
@@ -7,6 +7,7 @@ import { getConversation, setConversation, readAndTouchConversation, } from '@co
7
7
  import { mayWrite, mayRead } from '@coffer-org/server/orchestrator';
8
8
  import { policy } from "./config.js";
9
9
  import { makeStreamingConnector } from "./connector.js";
10
+ import { makeRefreshTool } from "./refresh-tool.js";
10
11
  import { broadcast, initFanout } from "./channel-registry.js";
11
12
  import { webChannelSystem } from "./format.js";
12
13
  import { loadReasoningDisplay } from "./settings.js";
@@ -87,6 +88,7 @@ export async function sendAction(body, caller, deps = {}) {
87
88
  replyToMsgId: replyTo,
88
89
  },
89
90
  capabilities: CAPABILITIES,
91
+ toolProvider: makeRefreshTool(conversationId),
90
92
  systemPrompt: { channel: [await webChannelSystem()] },
91
93
  ...(turnContext.length ? { turnContext } : {}),
92
94
  ...(agentId ? { agentId } : {}),
package/dist/web.js CHANGED
@@ -11,7 +11,7 @@ import { definePluginUI } from "@coffer-org/web-ui";
11
11
  var ui_default = definePluginUI({ slots: [{
12
12
  slot: "overlay",
13
13
  id: "webchat",
14
- load: () => import("./ChatWidget-BhGpxS9f.js")
14
+ load: () => import("./ChatWidget-DocoFt8q.js")
15
15
  }] });
16
16
  //#endregion
17
17
  export { ui_default as default };
package/locales/en.json CHANGED
@@ -21,6 +21,10 @@
21
21
  "stop": "Stop",
22
22
  "attach": "Attach file",
23
23
  "removeAttachment": "Remove {{name}}",
24
+ "attachments": "Attached files",
25
+ "moreFiles_one": "{{count}} more file",
26
+ "moreFiles_other": "{{count}} more files",
27
+ "fewerFiles": "Show fewer",
24
28
  "uploading": "Uploading…",
25
29
  "uploadProgress": "Upload progress",
26
30
  "uploadTooLarge": "File is too large (maximum 25 MB).",
@@ -32,9 +36,6 @@
32
36
  "error": "Error: {{message}}",
33
37
  "reasoning": "Reasoning",
34
38
  "agentPicker": "Agent and preset",
35
- "level": "Level {{value}} of {{max}}",
36
- "quality": "Quality {{value}} of 5",
37
- "time": "Time {{value}} of 5",
38
39
  "settings": {
39
40
  "label": "Web chat",
40
41
  "reasoning_display": "Reasoning display",
package/locales/ru.json CHANGED
@@ -21,6 +21,11 @@
21
21
  "stop": "Стоп",
22
22
  "attach": "Прикрепить файл",
23
23
  "removeAttachment": "Удалить {{name}}",
24
+ "attachments": "Прикреплённые файлы",
25
+ "moreFiles_one": "ещё {{count}} файл",
26
+ "moreFiles_few": "ещё {{count}} файла",
27
+ "moreFiles_many": "ещё {{count}} файлов",
28
+ "fewerFiles": "Свернуть",
24
29
  "uploading": "Загрузка…",
25
30
  "uploadProgress": "Прогресс загрузки",
26
31
  "uploadTooLarge": "Файл слишком большой (максимум 25 МБ).",
@@ -32,9 +37,6 @@
32
37
  "error": "Ошибка: {{message}}",
33
38
  "reasoning": "Рассуждения",
34
39
  "agentPicker": "Агент и пресет",
35
- "level": "Уровень {{value}} из {{max}}",
36
- "quality": "Качество {{value}} из 5",
37
- "time": "Время {{value}} из 5",
38
40
  "settings": {
39
41
  "label": "Чат на сайте",
40
42
  "reasoning_display": "Показ рассуждений",
package/locales/uk.json CHANGED
@@ -21,6 +21,11 @@
21
21
  "stop": "Стоп",
22
22
  "attach": "Додати файл",
23
23
  "removeAttachment": "Видалити {{name}}",
24
+ "attachments": "Прикріплені файли",
25
+ "moreFiles_one": "ще {{count}} файл",
26
+ "moreFiles_few": "ще {{count}} файли",
27
+ "moreFiles_many": "ще {{count}} файлів",
28
+ "fewerFiles": "Згорнути",
24
29
  "uploading": "Завантаження…",
25
30
  "uploadProgress": "Прогрес завантаження",
26
31
  "uploadTooLarge": "Файл завеликий (максимум 25 МБ).",
@@ -32,9 +37,6 @@
32
37
  "error": "Помилка: {{message}}",
33
38
  "reasoning": "Міркування",
34
39
  "agentPicker": "Агент і пресет",
35
- "level": "Рівень {{value}} з {{max}}",
36
- "quality": "Якість {{value}} з 5",
37
- "time": "Час {{value}} з 5",
38
40
  "settings": {
39
41
  "label": "Чат на сайті",
40
42
  "reasoning_display": "Показ міркувань",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffer-org/plugin-webchat",
3
- "version": "7.1.0",
3
+ "version": "7.3.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -28,8 +28,8 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@coffer-org/markdown": "^7.4.0",
31
- "@coffer-org/sdk": "^7.4.0",
32
- "@coffer-org/server": "^7.4.0",
31
+ "@coffer-org/sdk": "^7.4.1",
32
+ "@coffer-org/server": "^7.6.0",
33
33
  "lucide-react": "^1.17.0"
34
34
  },
35
35
  "coffer": {