@artooi/ag-ui-web-component 0.4.0 → 0.5.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.
- package/CHANGELOG.md +21 -1
- package/dist/ag-ui-web-component.bundle.js +238 -54
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/conversation_store.d.ts +37 -8
- package/dist/core/conversation_store.d.ts.map +1 -1
- package/dist/core/remote_conversation_store.d.ts +35 -0
- package/dist/core/remote_conversation_store.d.ts.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +621 -5
- package/dist/index.js.map +4 -4
- package/dist/ui/relative_time.d.ts +11 -0
- package/dist/ui/relative_time.d.ts.map +1 -0
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/thread_drawer.d.ts +33 -0
- package/dist/ui/thread_drawer.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/core/ag_ui_chat.ts +89 -2
- package/src/core/conversation_store.ts +148 -9
- package/src/core/remote_conversation_store.ts +147 -0
- package/src/index.ts +2 -0
- package/src/ui/relative_time.ts +28 -0
- package/src/ui/styles.ts +184 -0
- package/src/ui/thread_drawer.ts +200 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A compact relative timestamp for a thread row — e.g. `"just now"`, `"5m ago"`,
|
|
3
|
+
* `"3h ago"`, `"2d ago"`, `"4w ago"`.
|
|
4
|
+
*
|
|
5
|
+
* `now` is injectable so callers (and tests) can pin the reference point; it
|
|
6
|
+
* defaults to the current time. A timestamp in the future (clock skew) reads as
|
|
7
|
+
* `"just now"`. Kept locale-independent on purpose so the rendered label is
|
|
8
|
+
* stable across environments.
|
|
9
|
+
*/
|
|
10
|
+
export declare function relativeTime(timestamp: number, now?: number): string;
|
|
11
|
+
//# sourceMappingURL=relative_time.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"relative_time.d.ts","sourceRoot":"","sources":["../../src/ui/relative_time.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,GAAE,MAAmB,GAAG,MAAM,CAkBhF"}
|
package/dist/ui/styles.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const STYLES = "\n:host {\n /* Colors */\n --ag-ui-bg: #ffffff;\n --ag-ui-fg: #1a1a2e;\n --ag-ui-accent: #4f46e5;\n --ag-ui-user-bg: #4f46e5;\n --ag-ui-user-fg: #ffffff;\n --ag-ui-assistant-bg: #f1f1f6;\n --ag-ui-input-bg: var(--ag-ui-bg);\n --ag-ui-tool-bg: var(--ag-ui-assistant-bg);\n --ag-ui-tool-fg: var(--ag-ui-accent);\n --ag-ui-header-bg: var(--ag-ui-accent);\n --ag-ui-header-fg: #ffffff;\n --ag-ui-border: #e2e2ec;\n --ag-ui-radius: 12px;\n\n /* Status accents for tool-call cards. */\n --ag-ui-success: #15803d;\n --ag-ui-danger: #b91c1c;\n --ag-ui-muted: #6b7280;\n\n /* Surface \u2014 set --ag-ui-shadow: none for a flush, embedded panel. */\n --ag-ui-shadow: 0 12px 32px rgba(20, 20, 50, 0.18);\n --ag-ui-font: inherit;\n --ag-ui-font-size: 14px;\n --ag-ui-code-font: ui-monospace, \"SF Mono\", Menlo, monospace;\n\n /* Spacing \u2014 the density preset overrides these. */\n --ag-ui-space: 10px;\n --ag-ui-pad: 16px;\n --ag-ui-msg-pad: 8px 12px;\n --ag-ui-msg-radius: 14px;\n\n /* Layout \u2014 override from outside to dock the widget anywhere.\n Set --ag-ui-position: static (and place this element in your own\n grid/flex layout) to embed it in the page flow instead of floating. */\n --ag-ui-position: fixed;\n --ag-ui-z-index: 2147483000;\n --ag-ui-width: 380px;\n --ag-ui-height: 560px;\n --ag-ui-inset: auto 24px 24px auto;\n --ag-ui-max-width: calc(100vw - 48px);\n --ag-ui-max-height: calc(100vh - 48px);\n\n position: var(--ag-ui-position);\n inset: var(--ag-ui-inset);\n z-index: var(--ag-ui-z-index);\n width: var(--ag-ui-width);\n max-width: var(--ag-ui-max-width);\n height: var(--ag-ui-height);\n max-height: var(--ag-ui-max-height);\n display: flex;\n font-family: var(--ag-ui-font);\n font-size: var(--ag-ui-font-size);\n color: var(--ag-ui-fg);\n}\n\n/* \u2500\u2500 Themes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n Themes only re-set the colour variables; layout/spacing are unaffected.\n theme=\"auto\" follows the OS via prefers-color-scheme. */\n:host([theme=\"dark\"]) {\n --ag-ui-bg: #15151f;\n --ag-ui-fg: #e8e8f2;\n --ag-ui-assistant-bg: #25253a;\n --ag-ui-header-bg: #1f1f30;\n --ag-ui-header-fg: #e8e8f2;\n --ag-ui-border: #33334a;\n --ag-ui-muted: #9aa0b4;\n --ag-ui-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);\n}\n\n@media (prefers-color-scheme: dark) {\n :host([theme=\"auto\"]) {\n --ag-ui-bg: #15151f;\n --ag-ui-fg: #e8e8f2;\n --ag-ui-assistant-bg: #25253a;\n --ag-ui-header-bg: #1f1f30;\n --ag-ui-header-fg: #e8e8f2;\n --ag-ui-border: #33334a;\n --ag-ui-muted: #9aa0b4;\n --ag-ui-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);\n }\n}\n\n/* A terminal-flavoured \"code\" theme: dark, monospace, green accent. */\n:host([theme=\"code\"]) {\n --ag-ui-bg: #0d1117;\n --ag-ui-fg: #c9d1d9;\n --ag-ui-accent: #3fb950;\n --ag-ui-user-bg: #238636;\n --ag-ui-assistant-bg: #161b22;\n --ag-ui-header-bg: #010409;\n --ag-ui-header-fg: #c9d1d9;\n --ag-ui-border: #30363d;\n --ag-ui-muted: #8b949e;\n --ag-ui-font: var(--ag-ui-code-font);\n --ag-ui-shadow: 0 12px 32px rgba(0, 0, 0, 0.6);\n}\n\n/* \u2500\u2500 Density \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n:host([density=\"compact\"]) {\n --ag-ui-font-size: 13px;\n --ag-ui-space: 6px;\n --ag-ui-pad: 10px;\n --ag-ui-msg-pad: 5px 9px;\n --ag-ui-msg-radius: 10px;\n}\n\n/* \u2500\u2500 Placement presets \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n:host([placement=\"bottom-left\"]) {\n --ag-ui-inset: auto auto 24px 24px;\n}\n\n:host([placement=\"side\"]) {\n --ag-ui-inset: 0 0 0 auto;\n --ag-ui-width: 420px;\n --ag-ui-height: 100vh;\n --ag-ui-max-height: 100vh;\n --ag-ui-radius: 0;\n}\n\n:host([placement=\"full\"]) {\n --ag-ui-inset: 0;\n --ag-ui-width: 100vw;\n --ag-ui-height: 100vh;\n --ag-ui-max-width: 100vw;\n --ag-ui-max-height: 100vh;\n --ag-ui-radius: 0;\n}\n\n/* Embedded: drop the floating chrome and the high z-index stacking context so\n the widget lives in the host's own layout (fixes overlay/z-index clashes). */\n:host([placement=\"embedded\"]) {\n --ag-ui-position: static;\n --ag-ui-width: 100%;\n --ag-ui-height: 100%;\n --ag-ui-max-width: 100%;\n --ag-ui-max-height: 100%;\n --ag-ui-shadow: none;\n --ag-ui-z-index: auto;\n}\n\n.chat {\n position: relative;\n display: flex;\n flex-direction: column;\n flex: 1;\n min-height: 0;\n background: var(--ag-ui-bg);\n border: 1px solid var(--ag-ui-border);\n border-radius: var(--ag-ui-radius);\n box-shadow: var(--ag-ui-shadow);\n overflow: hidden;\n}\n\n.header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n padding: 10px 14px;\n border-bottom: 1px solid var(--ag-ui-border);\n background: var(--ag-ui-header-bg);\n color: var(--ag-ui-header-fg);\n}\n\n.header-title {\n font-weight: 600;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.header-controls {\n display: flex;\n gap: 2px;\n flex: none;\n}\n\n.header-btn {\n border: none;\n background: transparent;\n color: inherit;\n font: inherit;\n line-height: 1;\n padding: 4px 7px;\n border-radius: 6px;\n cursor: pointer;\n opacity: 0.85;\n}\n\n.header-btn:hover {\n opacity: 1;\n background: rgba(255, 255, 255, 0.18);\n}\n\n/* Collapsed: keep just the header bar \u2014 hide the whole body, and let the host\n shrink to the header. (A host can also fully hide the element itself.) */\n:host([collapsed]) {\n height: auto;\n max-height: none;\n}\n\n:host([collapsed]) .messages,\n:host([collapsed]) .input-row,\n:host([collapsed]) .skill-chips,\n:host([collapsed]) .skill-palette,\n:host([collapsed]) .skill-hint {\n display: none;\n}\n\n/* Edge-docked placements pin top *and* bottom, which would otherwise keep the\n panel full-height when collapsed; unpin the bottom so it shrinks to the\n header. (Floating/bottom-left pin only the bottom, so they already shrink.) */\n:host([collapsed][placement=\"side\"]),\n:host([collapsed][placement=\"full\"]) {\n bottom: auto;\n}\n\n.messages {\n flex: 1;\n overflow-y: auto;\n padding: var(--ag-ui-pad);\n display: flex;\n flex-direction: column;\n gap: var(--ag-ui-space);\n}\n\n.message {\n max-width: 80%;\n padding: var(--ag-ui-msg-pad);\n border-radius: var(--ag-ui-msg-radius);\n line-height: 1.4;\n white-space: pre-wrap;\n word-break: break-word;\n}\n\n/* \u2500\u2500 Incoming-text animations (data-text-animation) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n/* .message--restored (rehydrated history) is excluded: entrance animations are\n for freshly-arriving messages, not the whole transcript replaying on reload.\n Word mode is excluded implicitly \u2014 restored bubbles aren't wrapped. */\n:host([data-text-animation=\"fade\"]) .message--assistant:not(.message--restored) {\n animation: ag-ui-msg-fade 0.25s ease both;\n}\n\n@keyframes ag-ui-msg-fade {\n from { opacity: 0; transform: translateY(4px); }\n to { opacity: 1; transform: none; }\n}\n\n.message--assistant .word {\n animation: ag-ui-word-in 0.3s ease both;\n animation-delay: calc(var(--ag-ui-word-index, 0) * 35ms);\n}\n\n@keyframes ag-ui-word-in {\n from { opacity: 0; }\n to { opacity: 1; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n :host([data-text-animation=\"fade\"]) .message--assistant,\n .message--assistant .word {\n animation: none;\n }\n}\n\n.message--user {\n align-self: flex-end;\n background: var(--ag-ui-user-bg);\n color: var(--ag-ui-user-fg);\n border-bottom-right-radius: 4px;\n}\n\n.message--assistant {\n align-self: flex-start;\n background: var(--ag-ui-assistant-bg);\n border-bottom-left-radius: 4px;\n /* Assistant bubbles hold rendered markdown/HTML, so collapse the source\n whitespace the renderer leaves between block tags. */\n white-space: normal;\n}\n\n/* Rendered-markdown elements inside an assistant bubble. */\n.message--assistant > :first-child {\n margin-top: 0;\n}\n\n.message--assistant > :last-child {\n margin-bottom: 0;\n}\n\n.message--assistant p,\n.message--assistant ul,\n.message--assistant ol,\n.message--assistant blockquote,\n.message--assistant pre {\n margin: 0.5em 0;\n}\n\n.message--assistant ul,\n.message--assistant ol {\n padding-left: 1.3em;\n}\n\n.message--assistant a {\n color: var(--ag-ui-accent);\n text-decoration: underline;\n}\n\n.message--assistant code {\n font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n font-size: 0.92em;\n background: rgba(127, 127, 127, 0.16);\n padding: 1px 4px;\n border-radius: 4px;\n}\n\n.message--assistant pre {\n padding: 8px 10px;\n overflow: auto;\n background: var(--ag-ui-bg);\n border: 1px solid var(--ag-ui-border);\n border-radius: 6px;\n}\n\n.message--assistant pre code {\n background: none;\n padding: 0;\n}\n\n.message--assistant blockquote {\n padding-left: 10px;\n border-left: 3px solid var(--ag-ui-border);\n color: var(--ag-ui-muted);\n}\n\n.pending {\n align-self: flex-start;\n display: inline-flex;\n gap: 4px;\n align-items: center;\n padding: 12px 14px;\n background: var(--ag-ui-assistant-bg);\n border-radius: 14px;\n border-bottom-left-radius: 4px;\n}\n\n.pending-dot {\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: var(--ag-ui-muted);\n animation: ag-ui-pending 1.2s infinite ease-in-out both;\n}\n\n.pending-dot:nth-child(2) {\n animation-delay: 0.16s;\n}\n\n.pending-dot:nth-child(3) {\n animation-delay: 0.32s;\n}\n\n@keyframes ag-ui-pending {\n 0%, 80%, 100% { opacity: 0.3; transform: translateY(0); }\n 40% { opacity: 1; transform: translateY(-3px); }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .pending-dot {\n animation: none;\n opacity: 0.6;\n }\n}\n\n.tool-call {\n align-self: flex-start;\n max-width: 80%;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n gap: 6px;\n font-size: 12px;\n font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n padding: 8px 10px;\n border-radius: 8px;\n background: var(--ag-ui-tool-bg);\n border: 1px solid var(--ag-ui-border);\n color: var(--ag-ui-tool-fg);\n}\n\n.tool-call-head {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n}\n\n.tool-call-name {\n font-weight: 600;\n word-break: break-word;\n}\n\n.tool-call-status {\n flex: none;\n padding: 1px 8px;\n border-radius: 999px;\n font-size: 11px;\n font-weight: 600;\n background: rgba(127, 127, 127, 0.16);\n color: var(--ag-ui-muted);\n}\n\n.tool-call[data-status=\"done\"] .tool-call-status {\n color: var(--ag-ui-success);\n}\n\n.tool-call[data-status=\"error\"] .tool-call-status {\n color: var(--ag-ui-danger);\n}\n\n.tool-call[data-status=\"declined\"] .tool-call-status {\n color: var(--ag-ui-muted);\n}\n\n.tool-call-args,\n.tool-call-result {\n margin: 0;\n padding: 6px 8px;\n max-height: 160px;\n overflow: auto;\n background: var(--ag-ui-bg);\n border: 1px solid var(--ag-ui-border);\n border-radius: 6px;\n white-space: pre-wrap;\n word-break: break-word;\n color: var(--ag-ui-fg);\n}\n\n.tool-call-toggle {\n align-self: flex-start;\n border: none;\n padding: 0;\n background: none;\n font: inherit;\n font-weight: 600;\n color: var(--ag-ui-accent);\n cursor: pointer;\n}\n\n.tool-call-toggle::before {\n content: \"\u25B8 \";\n}\n\n.tool-call-toggle[aria-expanded=\"true\"]::before {\n content: \"\u25BE \";\n}\n\n.input-row {\n display: flex;\n gap: 8px;\n padding: 12px;\n border-top: 1px solid var(--ag-ui-border);\n}\n\n.input {\n flex: 1;\n resize: none;\n background: var(--ag-ui-input-bg);\n border: 1px solid var(--ag-ui-border);\n border-radius: 8px;\n padding: 8px 10px;\n font: inherit;\n color: inherit;\n outline: none;\n}\n\n.input:focus {\n border-color: var(--ag-ui-accent);\n}\n\n.send {\n border: none;\n border-radius: 8px;\n padding: 0 16px;\n background: var(--ag-ui-accent);\n color: #ffffff;\n font: inherit;\n font-weight: 600;\n cursor: pointer;\n}\n\n.send:disabled {\n opacity: 0.5;\n cursor: default;\n}\n\n/* The composer button doubles as the Stop control while a run is in flight. */\n.send[data-state=\"running\"] {\n background: var(--ag-ui-muted);\n}\n\n/* Muted \"\u23F9 Stopped\" line after a cancelled run \u2014 a note, not an error bubble. */\n.stopped-note {\n align-self: flex-start;\n color: var(--ag-ui-muted);\n font-size: 12px;\n padding: 2px 4px;\n}\n\n/* Inline confirmation card \u2014 lives in the transcript, no focus-stealing overlay. */\n.confirm {\n align-self: stretch;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n gap: 8px;\n padding: 12px;\n background: var(--ag-ui-bg);\n border: 1px solid var(--ag-ui-accent);\n border-radius: 10px;\n}\n\n.confirm[data-resolved] {\n opacity: 0.7;\n border-color: var(--ag-ui-border);\n}\n\n.confirm-body {\n font-weight: 600;\n}\n\n.confirm-args {\n margin: 0;\n padding: 8px 10px;\n max-height: 140px;\n overflow: auto;\n font-size: 12px;\n font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n background: var(--ag-ui-assistant-bg);\n border-radius: 8px;\n white-space: pre-wrap;\n word-break: break-word;\n}\n\n.confirm-actions {\n display: flex;\n gap: 8px;\n justify-content: flex-end;\n}\n\n.confirm-btn {\n border: 1px solid var(--ag-ui-border);\n border-radius: 8px;\n padding: 8px 14px;\n font: inherit;\n font-weight: 600;\n cursor: pointer;\n background: var(--ag-ui-bg);\n color: var(--ag-ui-fg);\n}\n\n.confirm-btn:disabled {\n cursor: default;\n opacity: 0.6;\n}\n\n.confirm-btn--confirm {\n border-color: var(--ag-ui-accent);\n background: var(--ag-ui-accent);\n color: #ffffff;\n}\n\n/* Skills \u2014 chips row + the /-command palette, above the input. */\n.skill-chips {\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n padding: 10px 12px;\n}\n\n.skill-chip {\n border: 1px solid var(--ag-ui-border);\n border-radius: 999px;\n padding: 4px 12px;\n font: inherit;\n font-size: 0.9em;\n cursor: pointer;\n background: var(--ag-ui-assistant-bg);\n color: var(--ag-ui-fg);\n}\n\n.skill-chip:hover {\n border-color: var(--ag-ui-accent);\n}\n\n.skill-palette {\n margin: 8px 12px 0;\n display: flex;\n flex-direction: column;\n max-height: 220px;\n overflow: auto;\n background: var(--ag-ui-bg);\n border: 1px solid var(--ag-ui-border);\n border-radius: 8px;\n box-shadow: 0 8px 24px rgba(20, 20, 50, 0.16);\n}\n\n.skill-item {\n display: flex;\n flex-direction: column;\n gap: 2px;\n align-items: flex-start;\n padding: 8px 12px;\n border: none;\n background: none;\n font: inherit;\n text-align: left;\n cursor: pointer;\n color: var(--ag-ui-fg);\n}\n\n.skill-item[aria-selected=\"true\"] {\n background: var(--ag-ui-assistant-bg);\n}\n\n.skill-item-title {\n font-weight: 600;\n}\n\n.skill-item-desc {\n font-size: 0.85em;\n color: var(--ag-ui-muted);\n}\n\n.skill-hint {\n margin: 8px 12px 0;\n font-size: 0.85em;\n color: var(--ag-ui-danger);\n}\n";
|
|
1
|
+
export declare const STYLES = "\n:host {\n /* Colors */\n --ag-ui-bg: #ffffff;\n --ag-ui-fg: #1a1a2e;\n --ag-ui-accent: #4f46e5;\n --ag-ui-user-bg: #4f46e5;\n --ag-ui-user-fg: #ffffff;\n --ag-ui-assistant-bg: #f1f1f6;\n --ag-ui-input-bg: var(--ag-ui-bg);\n --ag-ui-tool-bg: var(--ag-ui-assistant-bg);\n --ag-ui-tool-fg: var(--ag-ui-accent);\n --ag-ui-header-bg: var(--ag-ui-accent);\n --ag-ui-header-fg: #ffffff;\n --ag-ui-border: #e2e2ec;\n --ag-ui-radius: 12px;\n\n /* Status accents for tool-call cards. */\n --ag-ui-success: #15803d;\n --ag-ui-danger: #b91c1c;\n --ag-ui-muted: #6b7280;\n\n /* Surface \u2014 set --ag-ui-shadow: none for a flush, embedded panel. */\n --ag-ui-shadow: 0 12px 32px rgba(20, 20, 50, 0.18);\n --ag-ui-font: inherit;\n --ag-ui-font-size: 14px;\n --ag-ui-code-font: ui-monospace, \"SF Mono\", Menlo, monospace;\n\n /* Spacing \u2014 the density preset overrides these. */\n --ag-ui-space: 10px;\n --ag-ui-pad: 16px;\n --ag-ui-msg-pad: 8px 12px;\n --ag-ui-msg-radius: 14px;\n\n /* Layout \u2014 override from outside to dock the widget anywhere.\n Set --ag-ui-position: static (and place this element in your own\n grid/flex layout) to embed it in the page flow instead of floating. */\n --ag-ui-position: fixed;\n --ag-ui-z-index: 2147483000;\n --ag-ui-width: 380px;\n --ag-ui-height: 560px;\n --ag-ui-inset: auto 24px 24px auto;\n --ag-ui-max-width: calc(100vw - 48px);\n --ag-ui-max-height: calc(100vh - 48px);\n\n position: var(--ag-ui-position);\n inset: var(--ag-ui-inset);\n z-index: var(--ag-ui-z-index);\n width: var(--ag-ui-width);\n max-width: var(--ag-ui-max-width);\n height: var(--ag-ui-height);\n max-height: var(--ag-ui-max-height);\n display: flex;\n font-family: var(--ag-ui-font);\n font-size: var(--ag-ui-font-size);\n color: var(--ag-ui-fg);\n}\n\n/* \u2500\u2500 Themes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n Themes only re-set the colour variables; layout/spacing are unaffected.\n theme=\"auto\" follows the OS via prefers-color-scheme. */\n:host([theme=\"dark\"]) {\n --ag-ui-bg: #15151f;\n --ag-ui-fg: #e8e8f2;\n --ag-ui-assistant-bg: #25253a;\n --ag-ui-header-bg: #1f1f30;\n --ag-ui-header-fg: #e8e8f2;\n --ag-ui-border: #33334a;\n --ag-ui-muted: #9aa0b4;\n --ag-ui-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);\n}\n\n@media (prefers-color-scheme: dark) {\n :host([theme=\"auto\"]) {\n --ag-ui-bg: #15151f;\n --ag-ui-fg: #e8e8f2;\n --ag-ui-assistant-bg: #25253a;\n --ag-ui-header-bg: #1f1f30;\n --ag-ui-header-fg: #e8e8f2;\n --ag-ui-border: #33334a;\n --ag-ui-muted: #9aa0b4;\n --ag-ui-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);\n }\n}\n\n/* A terminal-flavoured \"code\" theme: dark, monospace, green accent. */\n:host([theme=\"code\"]) {\n --ag-ui-bg: #0d1117;\n --ag-ui-fg: #c9d1d9;\n --ag-ui-accent: #3fb950;\n --ag-ui-user-bg: #238636;\n --ag-ui-assistant-bg: #161b22;\n --ag-ui-header-bg: #010409;\n --ag-ui-header-fg: #c9d1d9;\n --ag-ui-border: #30363d;\n --ag-ui-muted: #8b949e;\n --ag-ui-font: var(--ag-ui-code-font);\n --ag-ui-shadow: 0 12px 32px rgba(0, 0, 0, 0.6);\n}\n\n/* \u2500\u2500 Density \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n:host([density=\"compact\"]) {\n --ag-ui-font-size: 13px;\n --ag-ui-space: 6px;\n --ag-ui-pad: 10px;\n --ag-ui-msg-pad: 5px 9px;\n --ag-ui-msg-radius: 10px;\n}\n\n/* \u2500\u2500 Placement presets \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n:host([placement=\"bottom-left\"]) {\n --ag-ui-inset: auto auto 24px 24px;\n}\n\n:host([placement=\"side\"]) {\n --ag-ui-inset: 0 0 0 auto;\n --ag-ui-width: 420px;\n --ag-ui-height: 100vh;\n --ag-ui-max-height: 100vh;\n --ag-ui-radius: 0;\n}\n\n:host([placement=\"full\"]) {\n --ag-ui-inset: 0;\n --ag-ui-width: 100vw;\n --ag-ui-height: 100vh;\n --ag-ui-max-width: 100vw;\n --ag-ui-max-height: 100vh;\n --ag-ui-radius: 0;\n}\n\n/* Embedded: drop the floating chrome and the high z-index stacking context so\n the widget lives in the host's own layout (fixes overlay/z-index clashes). */\n:host([placement=\"embedded\"]) {\n --ag-ui-position: static;\n --ag-ui-width: 100%;\n --ag-ui-height: 100%;\n --ag-ui-max-width: 100%;\n --ag-ui-max-height: 100%;\n --ag-ui-shadow: none;\n --ag-ui-z-index: auto;\n}\n\n.chat {\n position: relative;\n display: flex;\n flex-direction: column;\n flex: 1;\n min-height: 0;\n background: var(--ag-ui-bg);\n border: 1px solid var(--ag-ui-border);\n border-radius: var(--ag-ui-radius);\n box-shadow: var(--ag-ui-shadow);\n overflow: hidden;\n}\n\n.header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n padding: 10px 14px;\n border-bottom: 1px solid var(--ag-ui-border);\n background: var(--ag-ui-header-bg);\n color: var(--ag-ui-header-fg);\n}\n\n.header-title {\n font-weight: 600;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.header-controls {\n display: flex;\n gap: 2px;\n flex: none;\n}\n\n.header-btn {\n border: none;\n background: transparent;\n color: inherit;\n font: inherit;\n line-height: 1;\n padding: 4px 7px;\n border-radius: 6px;\n cursor: pointer;\n opacity: 0.85;\n}\n\n.header-btn:hover {\n opacity: 1;\n background: rgba(255, 255, 255, 0.18);\n}\n\n/* Collapsed: keep just the header bar \u2014 hide the whole body, and let the host\n shrink to the header. (A host can also fully hide the element itself.) */\n:host([collapsed]) {\n height: auto;\n max-height: none;\n}\n\n:host([collapsed]) .messages,\n:host([collapsed]) .input-row,\n:host([collapsed]) .skill-chips,\n:host([collapsed]) .skill-palette,\n:host([collapsed]) .skill-hint {\n display: none;\n}\n\n/* Edge-docked placements pin top *and* bottom, which would otherwise keep the\n panel full-height when collapsed; unpin the bottom so it shrinks to the\n header. (Floating/bottom-left pin only the bottom, so they already shrink.) */\n:host([collapsed][placement=\"side\"]),\n:host([collapsed][placement=\"full\"]) {\n bottom: auto;\n}\n\n.messages {\n flex: 1;\n overflow-y: auto;\n padding: var(--ag-ui-pad);\n display: flex;\n flex-direction: column;\n gap: var(--ag-ui-space);\n}\n\n.message {\n max-width: 80%;\n padding: var(--ag-ui-msg-pad);\n border-radius: var(--ag-ui-msg-radius);\n line-height: 1.4;\n white-space: pre-wrap;\n word-break: break-word;\n}\n\n/* \u2500\u2500 Incoming-text animations (data-text-animation) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n/* .message--restored (rehydrated history) is excluded: entrance animations are\n for freshly-arriving messages, not the whole transcript replaying on reload.\n Word mode is excluded implicitly \u2014 restored bubbles aren't wrapped. */\n:host([data-text-animation=\"fade\"]) .message--assistant:not(.message--restored) {\n animation: ag-ui-msg-fade 0.25s ease both;\n}\n\n@keyframes ag-ui-msg-fade {\n from { opacity: 0; transform: translateY(4px); }\n to { opacity: 1; transform: none; }\n}\n\n.message--assistant .word {\n animation: ag-ui-word-in 0.3s ease both;\n animation-delay: calc(var(--ag-ui-word-index, 0) * 35ms);\n}\n\n@keyframes ag-ui-word-in {\n from { opacity: 0; }\n to { opacity: 1; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n :host([data-text-animation=\"fade\"]) .message--assistant,\n .message--assistant .word {\n animation: none;\n }\n}\n\n.message--user {\n align-self: flex-end;\n background: var(--ag-ui-user-bg);\n color: var(--ag-ui-user-fg);\n border-bottom-right-radius: 4px;\n}\n\n.message--assistant {\n align-self: flex-start;\n background: var(--ag-ui-assistant-bg);\n border-bottom-left-radius: 4px;\n /* Assistant bubbles hold rendered markdown/HTML, so collapse the source\n whitespace the renderer leaves between block tags. */\n white-space: normal;\n}\n\n/* Rendered-markdown elements inside an assistant bubble. */\n.message--assistant > :first-child {\n margin-top: 0;\n}\n\n.message--assistant > :last-child {\n margin-bottom: 0;\n}\n\n.message--assistant p,\n.message--assistant ul,\n.message--assistant ol,\n.message--assistant blockquote,\n.message--assistant pre {\n margin: 0.5em 0;\n}\n\n.message--assistant ul,\n.message--assistant ol {\n padding-left: 1.3em;\n}\n\n.message--assistant a {\n color: var(--ag-ui-accent);\n text-decoration: underline;\n}\n\n.message--assistant code {\n font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n font-size: 0.92em;\n background: rgba(127, 127, 127, 0.16);\n padding: 1px 4px;\n border-radius: 4px;\n}\n\n.message--assistant pre {\n padding: 8px 10px;\n overflow: auto;\n background: var(--ag-ui-bg);\n border: 1px solid var(--ag-ui-border);\n border-radius: 6px;\n}\n\n.message--assistant pre code {\n background: none;\n padding: 0;\n}\n\n.message--assistant blockquote {\n padding-left: 10px;\n border-left: 3px solid var(--ag-ui-border);\n color: var(--ag-ui-muted);\n}\n\n.pending {\n align-self: flex-start;\n display: inline-flex;\n gap: 4px;\n align-items: center;\n padding: 12px 14px;\n background: var(--ag-ui-assistant-bg);\n border-radius: 14px;\n border-bottom-left-radius: 4px;\n}\n\n.pending-dot {\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: var(--ag-ui-muted);\n animation: ag-ui-pending 1.2s infinite ease-in-out both;\n}\n\n.pending-dot:nth-child(2) {\n animation-delay: 0.16s;\n}\n\n.pending-dot:nth-child(3) {\n animation-delay: 0.32s;\n}\n\n@keyframes ag-ui-pending {\n 0%, 80%, 100% { opacity: 0.3; transform: translateY(0); }\n 40% { opacity: 1; transform: translateY(-3px); }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .pending-dot {\n animation: none;\n opacity: 0.6;\n }\n}\n\n.tool-call {\n align-self: flex-start;\n max-width: 80%;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n gap: 6px;\n font-size: 12px;\n font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n padding: 8px 10px;\n border-radius: 8px;\n background: var(--ag-ui-tool-bg);\n border: 1px solid var(--ag-ui-border);\n color: var(--ag-ui-tool-fg);\n}\n\n.tool-call-head {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n}\n\n.tool-call-name {\n font-weight: 600;\n word-break: break-word;\n}\n\n.tool-call-status {\n flex: none;\n padding: 1px 8px;\n border-radius: 999px;\n font-size: 11px;\n font-weight: 600;\n background: rgba(127, 127, 127, 0.16);\n color: var(--ag-ui-muted);\n}\n\n.tool-call[data-status=\"done\"] .tool-call-status {\n color: var(--ag-ui-success);\n}\n\n.tool-call[data-status=\"error\"] .tool-call-status {\n color: var(--ag-ui-danger);\n}\n\n.tool-call[data-status=\"declined\"] .tool-call-status {\n color: var(--ag-ui-muted);\n}\n\n.tool-call-args,\n.tool-call-result {\n margin: 0;\n padding: 6px 8px;\n max-height: 160px;\n overflow: auto;\n background: var(--ag-ui-bg);\n border: 1px solid var(--ag-ui-border);\n border-radius: 6px;\n white-space: pre-wrap;\n word-break: break-word;\n color: var(--ag-ui-fg);\n}\n\n.tool-call-toggle {\n align-self: flex-start;\n border: none;\n padding: 0;\n background: none;\n font: inherit;\n font-weight: 600;\n color: var(--ag-ui-accent);\n cursor: pointer;\n}\n\n.tool-call-toggle::before {\n content: \"\u25B8 \";\n}\n\n.tool-call-toggle[aria-expanded=\"true\"]::before {\n content: \"\u25BE \";\n}\n\n.input-row {\n display: flex;\n gap: 8px;\n padding: 12px;\n border-top: 1px solid var(--ag-ui-border);\n}\n\n.input {\n flex: 1;\n resize: none;\n background: var(--ag-ui-input-bg);\n border: 1px solid var(--ag-ui-border);\n border-radius: 8px;\n padding: 8px 10px;\n font: inherit;\n color: inherit;\n outline: none;\n}\n\n.input:focus {\n border-color: var(--ag-ui-accent);\n}\n\n.send {\n border: none;\n border-radius: 8px;\n padding: 0 16px;\n background: var(--ag-ui-accent);\n color: #ffffff;\n font: inherit;\n font-weight: 600;\n cursor: pointer;\n}\n\n.send:disabled {\n opacity: 0.5;\n cursor: default;\n}\n\n/* The composer button doubles as the Stop control while a run is in flight. */\n.send[data-state=\"running\"] {\n background: var(--ag-ui-muted);\n}\n\n/* Muted \"\u23F9 Stopped\" line after a cancelled run \u2014 a note, not an error bubble. */\n.stopped-note {\n align-self: flex-start;\n color: var(--ag-ui-muted);\n font-size: 12px;\n padding: 2px 4px;\n}\n\n/* Inline confirmation card \u2014 lives in the transcript, no focus-stealing overlay. */\n.confirm {\n align-self: stretch;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n gap: 8px;\n padding: 12px;\n background: var(--ag-ui-bg);\n border: 1px solid var(--ag-ui-accent);\n border-radius: 10px;\n}\n\n.confirm[data-resolved] {\n opacity: 0.7;\n border-color: var(--ag-ui-border);\n}\n\n.confirm-body {\n font-weight: 600;\n}\n\n.confirm-args {\n margin: 0;\n padding: 8px 10px;\n max-height: 140px;\n overflow: auto;\n font-size: 12px;\n font-family: ui-monospace, \"SF Mono\", Menlo, monospace;\n background: var(--ag-ui-assistant-bg);\n border-radius: 8px;\n white-space: pre-wrap;\n word-break: break-word;\n}\n\n.confirm-actions {\n display: flex;\n gap: 8px;\n justify-content: flex-end;\n}\n\n.confirm-btn {\n border: 1px solid var(--ag-ui-border);\n border-radius: 8px;\n padding: 8px 14px;\n font: inherit;\n font-weight: 600;\n cursor: pointer;\n background: var(--ag-ui-bg);\n color: var(--ag-ui-fg);\n}\n\n.confirm-btn:disabled {\n cursor: default;\n opacity: 0.6;\n}\n\n.confirm-btn--confirm {\n border-color: var(--ag-ui-accent);\n background: var(--ag-ui-accent);\n color: #ffffff;\n}\n\n/* Skills \u2014 chips row + the /-command palette, above the input. */\n.skill-chips {\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n padding: 10px 12px;\n}\n\n.skill-chip {\n border: 1px solid var(--ag-ui-border);\n border-radius: 999px;\n padding: 4px 12px;\n font: inherit;\n font-size: 0.9em;\n cursor: pointer;\n background: var(--ag-ui-assistant-bg);\n color: var(--ag-ui-fg);\n}\n\n.skill-chip:hover {\n border-color: var(--ag-ui-accent);\n}\n\n.skill-palette {\n margin: 8px 12px 0;\n display: flex;\n flex-direction: column;\n max-height: 220px;\n overflow: auto;\n background: var(--ag-ui-bg);\n border: 1px solid var(--ag-ui-border);\n border-radius: 8px;\n box-shadow: 0 8px 24px rgba(20, 20, 50, 0.16);\n}\n\n.skill-item {\n display: flex;\n flex-direction: column;\n gap: 2px;\n align-items: flex-start;\n padding: 8px 12px;\n border: none;\n background: none;\n font: inherit;\n text-align: left;\n cursor: pointer;\n color: var(--ag-ui-fg);\n}\n\n.skill-item[aria-selected=\"true\"] {\n background: var(--ag-ui-assistant-bg);\n}\n\n.skill-item-title {\n font-weight: 600;\n}\n\n.skill-item-desc {\n font-size: 0.85em;\n color: var(--ag-ui-muted);\n}\n\n.skill-hint {\n margin: 8px 12px 0;\n font-size: 0.85em;\n color: var(--ag-ui-danger);\n}\n\n/* Chat-history drawer \u2014 a slide-over within the chat panel. */\n.drawer {\n position: absolute;\n inset: 0;\n z-index: 5;\n display: flex;\n}\n\n.drawer[hidden] {\n display: none;\n}\n\n.drawer-backdrop {\n position: absolute;\n inset: 0;\n background: rgba(20, 20, 50, 0.32);\n}\n\n.drawer-panel {\n position: relative;\n display: flex;\n flex-direction: column;\n width: min(300px, 85%);\n height: 100%;\n background: var(--ag-ui-bg);\n border-right: 1px solid var(--ag-ui-border);\n box-shadow: var(--ag-ui-shadow);\n overflow: hidden;\n}\n\n.drawer-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--ag-ui-space);\n padding: var(--ag-ui-pad);\n border-bottom: 1px solid var(--ag-ui-border);\n}\n\n.drawer-title {\n font-weight: 600;\n}\n\n.drawer-new {\n border: 1px solid var(--ag-ui-border);\n border-radius: var(--ag-ui-radius);\n background: var(--ag-ui-bg);\n color: var(--ag-ui-accent);\n padding: 4px 10px;\n font: inherit;\n font-size: 0.85em;\n cursor: pointer;\n}\n\n.drawer-list {\n flex: 1;\n min-height: 0;\n overflow-y: auto;\n}\n\n.drawer-empty {\n padding: var(--ag-ui-pad);\n font-size: 0.9em;\n color: var(--ag-ui-muted);\n}\n\n.drawer-row {\n display: flex;\n align-items: stretch;\n border-bottom: 1px solid var(--ag-ui-border);\n}\n\n.drawer-row--active {\n background: var(--ag-ui-assistant-bg);\n}\n\n.drawer-row-select {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n gap: 2px;\n padding: 8px 12px;\n border: none;\n background: none;\n color: inherit;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n\n.drawer-row-title {\n font-weight: 600;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n}\n\n.drawer-row-time {\n font-size: 0.72em;\n color: var(--ag-ui-muted);\n}\n\n.drawer-row-preview {\n font-size: 0.8em;\n color: var(--ag-ui-muted);\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n}\n\n.drawer-row-actions {\n display: flex;\n align-items: center;\n gap: 2px;\n padding: 0 6px;\n}\n\n.drawer-row-rename,\n.drawer-row-delete {\n border: none;\n background: none;\n color: var(--ag-ui-muted);\n font-size: 0.9em;\n padding: 4px;\n cursor: pointer;\n}\n\n.drawer-rename-input {\n flex: 1;\n min-width: 0;\n margin: 6px 10px;\n padding: 4px 8px;\n border: 1px solid var(--ag-ui-accent);\n border-radius: 6px;\n background: var(--ag-ui-input-bg);\n color: var(--ag-ui-fg);\n font: inherit;\n}\n\n.drawer-confirm {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 12px;\n font-size: 0.85em;\n}\n\n.drawer-confirm-label {\n color: var(--ag-ui-danger);\n}\n\n.drawer-confirm-yes {\n border: none;\n border-radius: 6px;\n background: var(--ag-ui-danger);\n color: #ffffff;\n padding: 3px 10px;\n font: inherit;\n cursor: pointer;\n}\n\n.drawer-confirm-no {\n border: 1px solid var(--ag-ui-border);\n border-radius: 6px;\n background: none;\n color: inherit;\n padding: 3px 10px;\n font: inherit;\n cursor: pointer;\n}\n\n/* Embedded placement: an inline, flush side panel rather than a dimmed,\n floating slide-over. */\n:host([placement=\"embedded\"]) .drawer-backdrop {\n background: none;\n}\n\n:host([placement=\"embedded\"]) .drawer-panel {\n width: 100%;\n border-right: none;\n box-shadow: none;\n}\n";
|
|
2
2
|
//# sourceMappingURL=styles.d.ts.map
|
package/dist/ui/styles.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"styles.d.ts","sourceRoot":"","sources":["../../src/ui/styles.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,MAAM,
|
|
1
|
+
{"version":3,"file":"styles.d.ts","sourceRoot":"","sources":["../../src/ui/styles.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,MAAM,o7lBA0zBlB,CAAC"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ThreadMeta } from "../core/conversation_store.js";
|
|
2
|
+
/** Actions the host ({@link AgUiChat}) wires to the drawer's rows. */
|
|
3
|
+
export interface ThreadDrawerCallbacks {
|
|
4
|
+
/** A row was picked — load that thread and make it active. */
|
|
5
|
+
readonly onSelect: (threadId: string) => void;
|
|
6
|
+
/** The "New chat" action — start a fresh thread. */
|
|
7
|
+
readonly onNew: () => void;
|
|
8
|
+
/** A row was renamed to `title`. */
|
|
9
|
+
readonly onRename: (threadId: string, title: string) => void;
|
|
10
|
+
/** A row was deleted (after the inline confirm). */
|
|
11
|
+
readonly onDelete: (threadId: string) => void;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The chat-history drawer: a slide-over listing the user's threads (title,
|
|
15
|
+
* relative time, preview), with select / new / rename / delete actions and an
|
|
16
|
+
* empty state. Pure DOM in the spirit of {@link SkillsMenu} — the host appends
|
|
17
|
+
* {@link element}, toggles it, feeds rows via {@link setThreads}, and acts on
|
|
18
|
+
* the callbacks. The drawer is a *view*: it does not mutate the store; after a
|
|
19
|
+
* callback the host updates the store and calls {@link setThreads} to refresh.
|
|
20
|
+
*/
|
|
21
|
+
export declare class ThreadDrawer {
|
|
22
|
+
#private;
|
|
23
|
+
/** The drawer root (backdrop + panel). Append to the chat shell; hidden until opened. */
|
|
24
|
+
readonly element: HTMLDivElement;
|
|
25
|
+
constructor(callbacks: ThreadDrawerCallbacks);
|
|
26
|
+
isOpen(): boolean;
|
|
27
|
+
open(): void;
|
|
28
|
+
close(): void;
|
|
29
|
+
toggle(): void;
|
|
30
|
+
/** Render the rows (or the empty state), highlighting the active thread. */
|
|
31
|
+
setThreads(threads: readonly ThreadMeta[], activeId: string): void;
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=thread_drawer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"thread_drawer.d.ts","sourceRoot":"","sources":["../../src/ui/thread_drawer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,+BAA+B,CAAC;AAGhE,sEAAsE;AACtE,MAAM,WAAW,qBAAqB;IACpC,8DAA8D;IAC9D,QAAQ,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9C,oDAAoD;IACpD,QAAQ,CAAC,KAAK,EAAE,MAAM,IAAI,CAAC;IAC3B,oCAAoC;IACpC,QAAQ,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7D,oDAAoD;IACpD,QAAQ,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;CAC/C;AAED;;;;;;;GAOG;AACH,qBAAa,YAAY;;IACvB,yFAAyF;IACzF,QAAQ,CAAC,OAAO,EAAE,cAAc,CAAC;gBAOrB,SAAS,EAAE,qBAAqB;IAsC5C,MAAM,IAAI,OAAO;IAIjB,IAAI,IAAI,IAAI;IAIZ,KAAK,IAAI,IAAI;IAIb,MAAM,IAAI,IAAI;IAId,4EAA4E;IAC5E,UAAU,CAAC,OAAO,EAAE,SAAS,UAAU,EAAE,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI;CAgHnE"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@artooi/ag-ui-web-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Framework-free <ag-ui-chat> Web Component over the AG-UI protocol. Drop-in chat sidebar with a pluggable client-side tool registry, DOM driver primitives, animations, and destructive-action confirmation modal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
package/src/core/ag_ui_chat.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { renderMarkdown } from "../ui/render_markdown.js";
|
|
|
24
24
|
import { wrapWords } from "../ui/reveal_words.js";
|
|
25
25
|
import { SkillsMenu } from "../ui/skills_menu.js";
|
|
26
26
|
import { STYLES } from "../ui/styles.js";
|
|
27
|
+
import { ThreadDrawer } from "../ui/thread_drawer.js";
|
|
27
28
|
import { ToolCallCard, type ToolDisplayMode } from "../ui/tool_call_card.js";
|
|
28
29
|
import {
|
|
29
30
|
AgUiClient,
|
|
@@ -37,6 +38,7 @@ import {
|
|
|
37
38
|
SessionStorageStore,
|
|
38
39
|
} from "./conversation_store.js";
|
|
39
40
|
import { type AgentFactory, createHttpAgent } from "./create_http_agent.js";
|
|
41
|
+
import { RemoteConversationStore } from "./remote_conversation_store.js";
|
|
40
42
|
|
|
41
43
|
/** The role a rendered chat message takes. */
|
|
42
44
|
export type MessageRole = (typeof MESSAGE_ROLE)[keyof typeof MESSAGE_ROLE];
|
|
@@ -192,6 +194,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
192
194
|
readonly #send: HTMLButtonElement;
|
|
193
195
|
readonly #title: HTMLSpanElement;
|
|
194
196
|
readonly #skillsMenu: SkillsMenu;
|
|
197
|
+
readonly #drawer: ThreadDrawer;
|
|
195
198
|
readonly #skillHint: HTMLDivElement;
|
|
196
199
|
|
|
197
200
|
#client: AgUiClient | null = null;
|
|
@@ -225,6 +228,22 @@ export class AgUiChat extends HTMLElement {
|
|
|
225
228
|
this.#title = document.createElement("span");
|
|
226
229
|
this.#skillHint = document.createElement("div");
|
|
227
230
|
this.#skillsMenu = new SkillsMenu((skill) => this.#applySkill(skill));
|
|
231
|
+
this.#drawer = new ThreadDrawer({
|
|
232
|
+
onSelect: (threadId) => {
|
|
233
|
+
void this.#switchThread(threadId);
|
|
234
|
+
},
|
|
235
|
+
onNew: () => {
|
|
236
|
+
this.newChat();
|
|
237
|
+
void this.#refreshDrawer();
|
|
238
|
+
},
|
|
239
|
+
onRename: (threadId, title) => {
|
|
240
|
+
this.conversationStore.renameThread(threadId, title);
|
|
241
|
+
void this.#refreshDrawer();
|
|
242
|
+
},
|
|
243
|
+
onDelete: (threadId) => {
|
|
244
|
+
this.#deleteThread(threadId);
|
|
245
|
+
},
|
|
246
|
+
});
|
|
228
247
|
}
|
|
229
248
|
|
|
230
249
|
/** Attributes whose late changes must reflect in already-rendered chrome. */
|
|
@@ -337,10 +356,28 @@ export class AgUiChat extends HTMLElement {
|
|
|
337
356
|
}
|
|
338
357
|
this.#initSkills();
|
|
339
358
|
void this.#fetchToolCatalog();
|
|
359
|
+
this.#wireThreadStore();
|
|
340
360
|
this.#threadId = this.conversationStore.threadId();
|
|
341
361
|
void this.#rehydrate();
|
|
342
362
|
}
|
|
343
363
|
|
|
364
|
+
/**
|
|
365
|
+
* When `data-threads-url` is set, route thread enumeration / load / rename /
|
|
366
|
+
* delete through that server endpoint (wrapping the current store as the
|
|
367
|
+
* client-only fallback), so the history drawer shows durable, cross-device
|
|
368
|
+
* threads. Without it, the client store's per-tab threads are used.
|
|
369
|
+
*/
|
|
370
|
+
#wireThreadStore(): void {
|
|
371
|
+
const url = this.getAttribute("data-threads-url");
|
|
372
|
+
if (url !== null) {
|
|
373
|
+
this.conversationStore = new RemoteConversationStore(
|
|
374
|
+
url,
|
|
375
|
+
() => this.headers,
|
|
376
|
+
this.conversationStore,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
344
381
|
/** Fetch the server tool-label catalog from `data-tools-url`, if set. */
|
|
345
382
|
async #fetchToolCatalog(): Promise<void> {
|
|
346
383
|
const url = this.getAttribute("data-tools-url");
|
|
@@ -473,6 +510,13 @@ export class AgUiChat extends HTMLElement {
|
|
|
473
510
|
// leave the old agent streaming into a cleared transcript.
|
|
474
511
|
this.#cancelRun();
|
|
475
512
|
this.conversationStore.clear(this.#threadId);
|
|
513
|
+
this.#resetState();
|
|
514
|
+
this.#threadId = this.conversationStore.threadId();
|
|
515
|
+
this.#setRunning(false);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Drop the in-memory run + transcript, leaving the thread id untouched. */
|
|
519
|
+
#resetState(): void {
|
|
476
520
|
this.#client = null;
|
|
477
521
|
this.#streamingBubble = null;
|
|
478
522
|
this.#hidePending();
|
|
@@ -480,8 +524,39 @@ export class AgUiChat extends HTMLElement {
|
|
|
480
524
|
this.#serverSettled.clear();
|
|
481
525
|
this.#initialMessages = [];
|
|
482
526
|
this.#messages.replaceChildren();
|
|
483
|
-
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Switch the active conversation to an existing thread and replay it. */
|
|
530
|
+
async #switchThread(threadId: string): Promise<void> {
|
|
531
|
+
if (threadId === this.#threadId) {
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
this.#cancelRun();
|
|
535
|
+
this.#resetState();
|
|
536
|
+
this.conversationStore.setActiveThread(threadId);
|
|
537
|
+
this.#threadId = threadId;
|
|
484
538
|
this.#setRunning(false);
|
|
539
|
+
await this.#rehydrate();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Delete a thread; if it was the active one, fall back to a fresh chat. */
|
|
543
|
+
#deleteThread(threadId: string): void {
|
|
544
|
+
const wasActive = threadId === this.#threadId;
|
|
545
|
+
if (wasActive) {
|
|
546
|
+
this.#cancelRun();
|
|
547
|
+
}
|
|
548
|
+
this.conversationStore.clear(threadId);
|
|
549
|
+
if (wasActive) {
|
|
550
|
+
this.#resetState();
|
|
551
|
+
this.#threadId = this.conversationStore.threadId();
|
|
552
|
+
this.#setRunning(false);
|
|
553
|
+
}
|
|
554
|
+
void this.#refreshDrawer();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Reload the drawer's thread list, marking the active thread. */
|
|
558
|
+
async #refreshDrawer(): Promise<void> {
|
|
559
|
+
this.#drawer.setThreads(await this.conversationStore.listThreads(), this.#threadId);
|
|
485
560
|
}
|
|
486
561
|
|
|
487
562
|
/**
|
|
@@ -613,6 +688,17 @@ export class AgUiChat extends HTMLElement {
|
|
|
613
688
|
const controls = document.createElement("div");
|
|
614
689
|
controls.className = "header-controls";
|
|
615
690
|
|
|
691
|
+
const history = document.createElement("button");
|
|
692
|
+
history.type = "button";
|
|
693
|
+
history.className = "header-btn header-btn--history";
|
|
694
|
+
history.title = "Chat history";
|
|
695
|
+
history.setAttribute("aria-label", "Chat history");
|
|
696
|
+
history.textContent = "☰";
|
|
697
|
+
history.addEventListener("click", () => {
|
|
698
|
+
void this.#refreshDrawer();
|
|
699
|
+
this.#drawer.open();
|
|
700
|
+
});
|
|
701
|
+
|
|
616
702
|
const newChat = document.createElement("button");
|
|
617
703
|
newChat.type = "button";
|
|
618
704
|
newChat.className = "header-btn header-btn--new";
|
|
@@ -629,7 +715,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
629
715
|
collapse.textContent = "—";
|
|
630
716
|
collapse.addEventListener("click", () => this.toggleCollapsed());
|
|
631
717
|
|
|
632
|
-
controls.append(newChat, collapse);
|
|
718
|
+
controls.append(history, newChat, collapse);
|
|
633
719
|
header.append(title, controls);
|
|
634
720
|
|
|
635
721
|
this.#messages.className = "messages";
|
|
@@ -676,6 +762,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
676
762
|
this.#skillsMenu.chips,
|
|
677
763
|
this.#skillHint,
|
|
678
764
|
inputRow,
|
|
765
|
+
this.#drawer.element,
|
|
679
766
|
);
|
|
680
767
|
this.#root.append(style, this.#chat);
|
|
681
768
|
}
|
|
@@ -12,6 +12,22 @@ export interface NavigationCheckpoint {
|
|
|
12
12
|
readonly toolCallId: string;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Lightweight metadata for one conversation — the thread-drawer row shape.
|
|
17
|
+
*
|
|
18
|
+
* Returned by {@link ClientConversationStore.listThreads} so the drawer can
|
|
19
|
+
* render a list without loading message bodies. `title` defaults to a
|
|
20
|
+
* truncation of the first user message (until an explicit rename); `preview`
|
|
21
|
+
* is a one-line excerpt of the latest message; `updatedAt` is epoch ms of the
|
|
22
|
+
* last change, used to order the list.
|
|
23
|
+
*/
|
|
24
|
+
export interface ThreadMeta {
|
|
25
|
+
readonly threadId: string;
|
|
26
|
+
readonly title: string;
|
|
27
|
+
readonly updatedAt: number;
|
|
28
|
+
readonly preview: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
15
31
|
/**
|
|
16
32
|
* Client-side persistence seam for the conversation and a pending-navigation
|
|
17
33
|
* checkpoint, keyed by `thread_id`.
|
|
@@ -19,36 +35,61 @@ export interface NavigationCheckpoint {
|
|
|
19
35
|
* The default {@link SessionStorageStore} keeps everything per-tab in
|
|
20
36
|
* `sessionStorage`, so the chat survives the full page reloads of a
|
|
21
37
|
* multi-page app. A host may inject a server-backed store instead (e.g. one
|
|
22
|
-
* that rehydrates from a history endpoint); `loadMessages`
|
|
23
|
-
* async-friendly. The checkpoint methods stay synchronous — the
|
|
24
|
-
* tiny local hint a server store can derive from history and no-op.
|
|
38
|
+
* that rehydrates from a history endpoint); `loadMessages` and `listThreads`
|
|
39
|
+
* are therefore async-friendly. The checkpoint methods stay synchronous — the
|
|
40
|
+
* marker is a tiny local hint a server store can derive from history and no-op.
|
|
41
|
+
*
|
|
42
|
+
* Thread enumeration (`listThreads` / `setActiveThread` / `renameThread`) backs
|
|
43
|
+
* the chat-history drawer; "delete a thread" reuses {@link clear} and "new
|
|
44
|
+
* chat" reuses {@link threadId} after clearing the active thread.
|
|
25
45
|
*/
|
|
26
46
|
export interface ClientConversationStore {
|
|
27
|
-
/**
|
|
47
|
+
/** The active conversation id, generated and persisted on first read. */
|
|
28
48
|
threadId(): string;
|
|
29
49
|
/** Load the persisted message history, or `null` when none exists. */
|
|
30
50
|
loadMessages(threadId: string): Promise<readonly Message[] | null>;
|
|
31
|
-
/** Persist the message history. */
|
|
51
|
+
/** Persist the message history (and refresh the thread's drawer metadata). */
|
|
32
52
|
saveMessages(threadId: string, messages: readonly Message[]): void;
|
|
33
53
|
/** Load the pending-navigation checkpoint, or `null` when none is set. */
|
|
34
54
|
loadCheckpoint(threadId: string): NavigationCheckpoint | null;
|
|
35
55
|
/** Set the pending-navigation checkpoint, or clear it when given `null`. */
|
|
36
56
|
saveCheckpoint(threadId: string, checkpoint: NavigationCheckpoint | null): void;
|
|
37
|
-
/** Forget the conversation and checkpoint (
|
|
57
|
+
/** Forget the conversation and checkpoint (a "delete thread" / "new chat"). */
|
|
38
58
|
clear(threadId: string): void;
|
|
59
|
+
/** The user's threads as drawer metadata (no message bodies), newest first. */
|
|
60
|
+
listThreads(): Promise<readonly ThreadMeta[]>;
|
|
61
|
+
/** Make `threadId` the active conversation (the drawer selecting a row). */
|
|
62
|
+
setActiveThread(threadId: string): void;
|
|
63
|
+
/** Set a thread's display title (the drawer renaming a row). */
|
|
64
|
+
renameThread(threadId: string, title: string): void;
|
|
39
65
|
}
|
|
40
66
|
|
|
41
67
|
const THREAD_KEY = "ag-ui-chat:thread";
|
|
68
|
+
const THREADS_KEY = "ag-ui-chat:threads";
|
|
42
69
|
const MESSAGES_PREFIX = "ag-ui-chat:messages:";
|
|
43
70
|
const CHECKPOINT_PREFIX = "ag-ui-chat:checkpoint:";
|
|
44
71
|
|
|
72
|
+
const TITLE_LIMIT = 60;
|
|
73
|
+
const PREVIEW_LIMIT = 100;
|
|
74
|
+
const DEFAULT_TITLE = "New conversation";
|
|
75
|
+
|
|
76
|
+
/** The drawer-index entry; `titleCustom` (private) freezes a renamed title. */
|
|
77
|
+
interface StoredThread {
|
|
78
|
+
threadId: string;
|
|
79
|
+
title: string;
|
|
80
|
+
titleCustom: boolean;
|
|
81
|
+
preview: string;
|
|
82
|
+
updatedAt: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
45
85
|
/**
|
|
46
86
|
* Default {@link ClientConversationStore}: per-tab `sessionStorage`.
|
|
47
87
|
*
|
|
48
88
|
* Survives full page reloads and same-tab navigation, clears on tab close —
|
|
49
89
|
* the right scope for an embedded agent's conversation in a multi-page app.
|
|
50
|
-
*
|
|
51
|
-
*
|
|
90
|
+
* Tracks multiple threads per tab: the active id lives under one key, the
|
|
91
|
+
* message history / checkpoint are namespaced by id, and a small index feeds
|
|
92
|
+
* the drawer so it works with no server.
|
|
52
93
|
*/
|
|
53
94
|
export class SessionStorageStore implements ClientConversationStore {
|
|
54
95
|
threadId(): string {
|
|
@@ -67,6 +108,7 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
67
108
|
|
|
68
109
|
saveMessages(threadId: string, messages: readonly Message[]): void {
|
|
69
110
|
sessionStorage.setItem(MESSAGES_PREFIX + threadId, JSON.stringify(messages));
|
|
111
|
+
this.#touchThread(threadId, messages);
|
|
70
112
|
}
|
|
71
113
|
|
|
72
114
|
loadCheckpoint(threadId: string): NavigationCheckpoint | null {
|
|
@@ -85,7 +127,71 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
85
127
|
clear(threadId: string): void {
|
|
86
128
|
sessionStorage.removeItem(MESSAGES_PREFIX + threadId);
|
|
87
129
|
sessionStorage.removeItem(CHECKPOINT_PREFIX + threadId);
|
|
88
|
-
|
|
130
|
+
this.#writeThreads(this.#readThreads().filter((thread) => thread.threadId !== threadId));
|
|
131
|
+
// Only drop the active pointer when the active thread itself is cleared, so
|
|
132
|
+
// the next `threadId()` mints a fresh one. Deleting another thread from the
|
|
133
|
+
// drawer must not disturb the conversation on screen.
|
|
134
|
+
if (sessionStorage.getItem(THREAD_KEY) === threadId) {
|
|
135
|
+
sessionStorage.removeItem(THREAD_KEY);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
listThreads(): Promise<readonly ThreadMeta[]> {
|
|
140
|
+
const metas = this.#readThreads()
|
|
141
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
142
|
+
.map(({ threadId, title, updatedAt, preview }) => ({ threadId, title, updatedAt, preview }));
|
|
143
|
+
return Promise.resolve(metas);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
setActiveThread(threadId: string): void {
|
|
147
|
+
sessionStorage.setItem(THREAD_KEY, threadId);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
renameThread(threadId: string, title: string): void {
|
|
151
|
+
const threads = this.#readThreads();
|
|
152
|
+
const entry = threads.find((thread) => thread.threadId === threadId);
|
|
153
|
+
if (entry === undefined) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
entry.title = title;
|
|
157
|
+
entry.titleCustom = true;
|
|
158
|
+
this.#writeThreads(threads);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Add or refresh a thread's drawer metadata from its latest messages. */
|
|
162
|
+
#touchThread(threadId: string, messages: readonly Message[]): void {
|
|
163
|
+
const threads = this.#readThreads();
|
|
164
|
+
const entry = threads.find((thread) => thread.threadId === threadId);
|
|
165
|
+
const preview = derivePreview(messages);
|
|
166
|
+
const updatedAt = Date.now();
|
|
167
|
+
if (entry === undefined) {
|
|
168
|
+
threads.push({
|
|
169
|
+
threadId,
|
|
170
|
+
title: deriveTitle(messages),
|
|
171
|
+
titleCustom: false,
|
|
172
|
+
preview,
|
|
173
|
+
updatedAt,
|
|
174
|
+
});
|
|
175
|
+
} else {
|
|
176
|
+
entry.preview = preview;
|
|
177
|
+
entry.updatedAt = updatedAt;
|
|
178
|
+
if (!entry.titleCustom) {
|
|
179
|
+
entry.title = deriveTitle(messages);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
this.#writeThreads(threads);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#readThreads(): StoredThread[] {
|
|
186
|
+
return this.#readJson<StoredThread[]>(THREADS_KEY) ?? [];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#writeThreads(threads: readonly StoredThread[]): void {
|
|
190
|
+
if (threads.length === 0) {
|
|
191
|
+
sessionStorage.removeItem(THREADS_KEY);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
sessionStorage.setItem(THREADS_KEY, JSON.stringify(threads));
|
|
89
195
|
}
|
|
90
196
|
|
|
91
197
|
/** Parse a stored JSON value, returning `null` when absent or corrupt. */
|
|
@@ -101,3 +207,36 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
101
207
|
}
|
|
102
208
|
}
|
|
103
209
|
}
|
|
210
|
+
|
|
211
|
+
/** The thread title: the first user message, collapsed + truncated. */
|
|
212
|
+
function deriveTitle(messages: readonly Message[]): string {
|
|
213
|
+
for (const message of messages) {
|
|
214
|
+
if (message.role === "user") {
|
|
215
|
+
const text = cleanText(message.content);
|
|
216
|
+
if (text !== "") {
|
|
217
|
+
return truncate(text, TITLE_LIMIT);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return DEFAULT_TITLE;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** A one-line preview: the latest message with text, collapsed + truncated. */
|
|
225
|
+
function derivePreview(messages: readonly Message[]): string {
|
|
226
|
+
for (const message of [...messages].reverse()) {
|
|
227
|
+
const text = cleanText(message.content);
|
|
228
|
+
if (text !== "") {
|
|
229
|
+
return truncate(text, PREVIEW_LIMIT);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return "";
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Whitespace-collapsed message text, or `""` for non-string content. */
|
|
236
|
+
function cleanText(content: unknown): string {
|
|
237
|
+
return typeof content === "string" ? content.replace(/\s+/g, " ").trim() : "";
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function truncate(text: string, limit: number): string {
|
|
241
|
+
return text.length <= limit ? text : `${text.slice(0, limit - 1).trimEnd()}…`;
|
|
242
|
+
}
|