@paramms/chat-widget 0.1.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.
@@ -0,0 +1,906 @@
1
+ import type { ManifestAction, MessageContent, AnnotationStroke } from './protocol/index.js'
2
+ import type { ChatStore, RenderMessage } from './store.js'
3
+
4
+ export interface WidgetConfig {
5
+ subject?: { title?: string; subtitle?: string; tags?: string[]; status?: string; ownerLabel?: string }
6
+ quickReplies?: string[]
7
+ accent?: string
8
+ }
9
+
10
+ export interface RendererHandlers {
11
+ onSend(text: string): void
12
+ onAttach?(file: File): void
13
+ onInvoke(actionId: string, inputs?: Record<string, unknown>): void
14
+ onTyping(isTyping: boolean): void
15
+ onReadUpTo(seq: number): void
16
+ onReact?(messageId: string, emoji: string, remove: boolean): void
17
+ onCsat?(score: number): void
18
+ onLoadMore?(): void
19
+ onEdit?(messageId: string, newText: string): void
20
+ onDelete?(messageId: string): void
21
+ /** Co-browsing: a freehand stroke was completed on the shared whiteboard. */
22
+ onAnnotate?(stroke: Omit<AnnotationStroke, 'by'>): void
23
+ /** Co-browsing: clear the shared whiteboard for everyone. */
24
+ onAnnotateClear?(): void
25
+ /** Translate a message's text for display. Return null if unavailable โ€”
26
+ * the renderer shows a brief "unavailable" hint and leaves the original. */
27
+ onTranslate?(text: string): Promise<string | null>
28
+ /** Multi-subject chat list (e.g. a marketplace with one thread per item):
29
+ * fetch the guest's other conversations. Omit to hide the list button
30
+ * entirely โ€” single-conversation embeds don't need this. */
31
+ onListChats?(): Promise<ChatListEntry[]>
32
+ /** Switch the active conversation to a different one from the list โ€”
33
+ * effectively a re-open with a different subjectId. */
34
+ onSwitchChat?(entry: ChatListEntry): void
35
+ }
36
+
37
+ export interface ChatListEntry {
38
+ id: string
39
+ subjectId?: string
40
+ subjectTitle?: string
41
+ state: string
42
+ updatedAt: number
43
+ }
44
+
45
+ const STYLE_ID = 'objectchat-widget-styles'
46
+ const REACTION_EMOJIS = ['๐Ÿ‘', 'โค๏ธ', '๐Ÿ˜‚', '๐Ÿ˜ฎ', '๐Ÿ˜ข', '๐Ÿ™']
47
+ const CSS = `
48
+ .ocw { --ocw-accent:#f5713c; --ocw-bg:#f3efe9; --ocw-card:#fff; --ocw-line:#ececec; --ocw-ink:#1c1b1a; --ocw-mut:#9b9690;
49
+ position:relative;
50
+ display:flex; flex-direction:column; height:100%; min-height:520px; background:var(--ocw-bg);
51
+ font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; color:var(--ocw-ink); overflow:hidden; }
52
+ .ocw-head { display:flex; align-items:center; gap:10px; padding:12px 14px; background:var(--ocw-card); border-bottom:1px solid var(--ocw-line); }
53
+ .ocw-avatar { width:34px; height:34px; border-radius:50%; background:#ffe9d6; display:flex; align-items:center; justify-content:center; font-size:17px; flex:none; }
54
+ .ocw-head-main { flex:1; min-width:0; }
55
+ .ocw-head-name { font-weight:700; font-size:15px; }
56
+ .ocw-head-meta { color:var(--ocw-mut); font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
57
+ .ocw-badge { font-size:12px; font-weight:600; color:#15803d; background:#e8f6ec; border-radius:999px; padding:3px 10px; }
58
+ .ocw-e2e { font-size:11px; font-weight:700; color:#3730a3; background:#eef2ff; border-radius:999px; padding:3px 9px; align-items:center; }
59
+ .ocw-menu { color:var(--ocw-mut); width:30px; height:30px; border-radius:50%; border:1px solid var(--ocw-line); background:#fff; cursor:pointer; }
60
+ .ocw-chiprow { display:flex; gap:8px; padding:10px 12px; background:var(--ocw-card); border-bottom:1px solid var(--ocw-line); overflow-x:auto; }
61
+ .ocw-chip { flex:none; display:flex; align-items:center; gap:6px; border:1px solid #e3ded7; background:#fff; border-radius:999px; padding:7px 13px; font-size:13px; font-weight:600; cursor:pointer; white-space:nowrap; }
62
+ .ocw-chip:hover { border-color:var(--ocw-accent); color:var(--ocw-accent); }
63
+ .ocw-scroll { flex:1; min-height:0; overflow-y:auto; padding:16px 14px; display:flex; flex-direction:column; gap:10px; }
64
+ .ocw-subject { background:var(--ocw-card); border:1px solid var(--ocw-line); border-radius:16px; padding:14px 16px; }
65
+ .ocw-subject-title { font-weight:700; font-size:16px; margin-bottom:2px; }
66
+ .ocw-subject-sub { color:var(--ocw-mut); font-size:13px; margin-bottom:10px; }
67
+ .ocw-tags { display:flex; flex-wrap:wrap; gap:6px; }
68
+ .ocw-tag { font-size:12px; color:#5b554e; background:#efeae3; border-radius:8px; padding:4px 10px; }
69
+ .ocw-row { display:flex; align-items:flex-end; gap:8px; max-width:86%; }
70
+ .ocw-row.mine { align-self:flex-end; flex-direction:row-reverse; }
71
+ .ocw-row.theirs { align-self:flex-start; }
72
+ .ocw-dot { width:26px; height:26px; border-radius:50%; background:var(--ocw-accent); color:#fff; font-size:12px; font-weight:700; display:flex; align-items:center; justify-content:center; flex:none; }
73
+ .ocw-bubble { padding:10px 14px; border-radius:18px; font-size:14.5px; line-height:1.4; word-wrap:break-word; }
74
+ .theirs .ocw-bubble { background:#fff; border:1px solid var(--ocw-line); border-bottom-left-radius:6px; }
75
+ .mine .ocw-bubble { background:var(--ocw-accent); color:#fff; border-bottom-right-radius:6px; }
76
+ .ocw-sys { align-self:center; color:var(--ocw-mut); font-size:12.5px; font-style:italic; text-align:center; max-width:90%; }
77
+ .ocw-bot .ocw-bubble { background:#f0fdf4; border-color:#cdebd6; }
78
+ .ocw-note .ocw-bubble { background:#fffbeb; border:1.5px dashed #f59e0b; color:#78350f; border-radius:12px !important; }
79
+ .ocw-note .ocw-bubble::before { content:'๐Ÿ”’ Note โ€” '; font-size:11px; font-weight:700; color:#b45309; display:block; margin-bottom:3px; letter-spacing:.3px; }
80
+ .ocw-time { font-size:10.5px; color:var(--ocw-mut); margin-top:3px; }
81
+ .mine .ocw-meta { text-align:right; }
82
+ .ocw-tick { margin-left:4px; }
83
+ .ocw-deleted { font-style:italic; color:var(--ocw-mut); }
84
+ .ocw-edited { font-size:10px; color:var(--ocw-mut); margin-left:4px; }
85
+ .ocw-react { font-size:12px; margin-top:3px; display:flex; flex-wrap:wrap; gap:3px; }
86
+ .ocw-react-pill { display:inline-flex; align-items:center; gap:3px; border:1px solid #e3ded7; border-radius:999px; padding:2px 7px; background:#fff; font-size:12px; cursor:pointer; }
87
+ .ocw-react-pill:hover { border-color:var(--ocw-accent); }
88
+ .ocw-react-pill.mine { border-color:var(--ocw-accent); background:#fff8f5; }
89
+ .ocw-react-add { display:none; position:absolute; bottom:100%; left:0; margin-bottom:4px; background:#fff; border:1px solid #e3ded7; border-radius:14px; padding:6px 8px; box-shadow:0 4px 16px rgba(0,0,0,.12); display:flex; gap:4px; z-index:10; }
90
+ .ocw-react-wrap { position:relative; }
91
+ .ocw-react-wrap:not(:hover) .ocw-react-picker { display:none; }
92
+ .ocw-react-picker { position:absolute; bottom:calc(100% + 4px); left:0; background:#fff; border:1px solid #e3ded7; border-radius:14px; padding:6px 8px; box-shadow:0 4px 16px rgba(0,0,0,.12); display:flex; gap:4px; z-index:10; white-space:nowrap; }
93
+ .ocw-react-picker button { background:none; border:none; font-size:16px; cursor:pointer; padding:2px; border-radius:6px; }
94
+ .ocw-react-picker button:hover { background:#f3efe9; }
95
+ .ocw-react-btn { background:none; border:1px solid #e3ded7; border-radius:999px; padding:2px 7px; font-size:12px; cursor:pointer; color:var(--ocw-mut); }
96
+ .ocw-react-btn:hover { border-color:var(--ocw-accent); color:var(--ocw-accent); }
97
+ .ocw-msg-menu { position:absolute; top:0; right:0; display:none; gap:3px; }
98
+ .ocw-row.mine:hover .ocw-msg-menu { display:flex; }
99
+ .ocw-row.theirs:hover .ocw-msg-menu { display:flex; left:0; right:auto; }
100
+ .ocw-msg-menu button { background:#fff; border:1px solid #e3ded7; border-radius:6px; font-size:11px; padding:2px 6px; cursor:pointer; color:var(--ocw-mut); }
101
+ .ocw-msg-menu button:hover { border-color:var(--ocw-accent); color:var(--ocw-accent); }
102
+ .ocw-msg-menu button.del:hover { border-color:#e74c3c; color:#e74c3c; }
103
+ .ocw-bubble-wrap { position:relative; }
104
+ .ocw-seen { font-size:10.5px; color:var(--ocw-mut); }
105
+ .ocw-appt { background:#f0f7ff; border:1px solid #c7deff; border-radius:12px; padding:12px 14px; max-width:260px; }
106
+ .ocw-appt-title { font-weight:700; font-size:14px; margin-bottom:4px; }
107
+ .ocw-appt-time { font-size:12px; color:#1d4ed8; margin-bottom:4px; }
108
+ .ocw-appt-loc { font-size:12px; color:var(--ocw-mut); margin-bottom:4px; }
109
+ .ocw-appt-desc { font-size:12px; color:var(--ocw-mut); margin-bottom:10px; white-space:pre-wrap; }
110
+ .ocw-appt-links { display:flex; flex-direction:column; gap:6px; }
111
+ .ocw-appt-btn { display:block; text-align:center; padding:8px 12px; border-radius:8px; font-size:13px; font-weight:600; text-decoration:none; background:var(--ocw-accent); color:#fff; }
112
+ .ocw-appt-btn-sec { background:#fff; color:var(--ocw-accent); border:1px solid var(--ocw-accent); }
113
+ .ocw-conn-status { font-size:10px; color:var(--ocw-mut); margin-left:4px; }
114
+ .ocw-conn-status.warn { color:#e67e22; }
115
+ .ocw-load-more { display:block; width:100%; background:none; border:1px solid #e3ded7; border-radius:10px; padding:6px 0; font-size:12px; color:var(--ocw-mut); cursor:pointer; margin-bottom:8px; }
116
+ .ocw-load-more:hover { border-color:var(--ocw-accent); color:var(--ocw-accent); }
117
+ .ocw-offline { margin:20px 14px; padding:20px; background:#fff; border:1px solid #e3ded7; border-radius:16px; text-align:center; }
118
+ .ocw-offline-icon { font-size:32px; margin-bottom:8px; }
119
+ .ocw-offline-title { font-weight:700; font-size:16px; margin-bottom:6px; }
120
+ .ocw-offline-msg { font-size:13px; color:var(--ocw-mut); margin-bottom:16px; }
121
+ .ocw-offline-form { display:flex; flex-direction:column; gap:8px; text-align:left; }
122
+ .ocw-offline-input { border:1px solid #e3ded7; border-radius:10px; padding:10px 12px; font-size:13px; font-family:inherit; }
123
+ .ocw-offline-input:focus { outline:none; border-color:var(--ocw-accent); }
124
+ .ocw-offline-submit { background:var(--ocw-accent); color:#fff; border:none; border-radius:10px; padding:11px; font-size:14px; font-weight:600; cursor:pointer; }
125
+ .ocw-offline-thanks { font-size:14px; color:#15803d; font-weight:600; }
126
+ .ocw-csat-title { font-size:13px; font-weight:600; margin-bottom:8px; }
127
+ .ocw-csat-stars { display:flex; gap:6px; }
128
+ .ocw-csat-star { background:none; border:none; font-size:22px; cursor:pointer; padding:2px; opacity:.4; transition:opacity .15s; }
129
+ .ocw-csat-star:hover, .ocw-csat-star.lit { opacity:1; }
130
+ .ocw-csat-done { font-size:12px; color:var(--ocw-mut); margin-top:6px; }
131
+
132
+ .ocw-typing { height:18px; padding:0 16px 4px; font-size:12px; color:var(--ocw-mut); }
133
+ .ocw-quick { display:flex; gap:8px; padding:8px 12px 0; overflow-x:auto; }
134
+ .ocw-quick button { flex:none; border:1px solid #e3ded7; background:#fff; border-radius:999px; padding:7px 14px; font-size:13px; cursor:pointer; color:#444; white-space:nowrap; }
135
+ .ocw-quick button:hover { border-color:var(--ocw-accent); color:var(--ocw-accent); }
136
+ .ocw-form-host:empty { display:none; }
137
+ .ocw-form { margin:6px 12px 0; padding:12px; background:var(--ocw-card); border:1px solid var(--ocw-line); border-radius:14px; }
138
+ .ocw-form-title { font-weight:700; font-size:14px; margin-bottom:8px; }
139
+ .ocw-form-row { display:flex; flex-direction:column; gap:3px; margin-bottom:8px; }
140
+ .ocw-form-lbl { font-size:12px; color:var(--ocw-mut); }
141
+ .ocw-form-input { border:1px solid #e3ded7; border-radius:9px; padding:9px 11px; font:inherit; font-size:14px; outline:none; }
142
+ .ocw-form-input:focus { border-color:var(--ocw-accent); }
143
+ .ocw-form-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:4px; }
144
+ .ocw-form-cancel { background:none; border:none; color:var(--ocw-mut); font-size:13px; cursor:pointer; padding:8px 10px; }
145
+ .ocw-form-submit { background:var(--ocw-accent); color:#fff; border:none; border-radius:999px; padding:8px 18px; font-size:13px; font-weight:600; cursor:pointer; }
146
+ .ocw-modal { position:absolute; inset:0; background:rgba(20,18,16,.42); display:flex; align-items:center; justify-content:center; z-index:50; }
147
+ .ocw-modal-card { background:#fff; border-radius:16px; padding:20px; width:78%; max-width:300px; box-shadow:0 14px 44px rgba(0,0,0,.22); }
148
+ .ocw-modal-title { font-weight:700; font-size:16px; margin-bottom:6px; }
149
+ .ocw-modal-body { color:var(--ocw-mut); font-size:14px; margin-bottom:16px; }
150
+ .ocw-modal-actions { display:flex; justify-content:flex-end; gap:8px; }
151
+ .ocw-modal-cancel { background:none; border:none; color:var(--ocw-mut); font-size:14px; cursor:pointer; padding:9px 12px; }
152
+ .ocw-modal-ok { background:var(--ocw-accent); color:#fff; border:none; border-radius:999px; padding:9px 20px; font-size:14px; font-weight:600; cursor:pointer; }
153
+ .ocw-input { display:flex; align-items:center; gap:10px; padding:12px; }
154
+ .ocw-footer { text-align:center; font-size:11px; color:var(--ocw-mut); padding:6px 0 8px; }
155
+ .ocw-footer a { color:var(--ocw-mut); text-decoration:none; font-weight:600; }
156
+ .ocw-footer a:hover { color:var(--ocw-accent); }
157
+ .ocw-attach { background:none;border:none;cursor:pointer;font-size:18px;padding:4px 6px;opacity:.6;flex-none; }
158
+ .ocw-attach:hover { opacity:1; }
159
+ .ocw-input textarea { flex:1; resize:none; border:1px solid #e3ded7; border-radius:22px; padding:11px 16px; font:inherit; font-size:14px; background:#fff; outline:none; max-height:96px; }
160
+ .ocw-input textarea:focus { border-color:var(--ocw-accent); }
161
+ .ocw-sendbtn { width:42px; height:42px; border-radius:50%; border:none; background:var(--ocw-accent); color:#fff; font-size:18px; cursor:pointer; flex:none; display:flex; align-items:center; justify-content:center; }
162
+ .ocw-sendbtn:disabled { opacity:.5; cursor:default; }
163
+ .ocw-cobrowse-btn { display:none; }
164
+ .ocw-cobrowse-btn.show { display:inline-flex; }
165
+ .ocw-cobrowse-btn.on { color:var(--ocw-accent); border-color:var(--ocw-accent); }
166
+ .ocw-cobrowse-canvas { position:absolute; inset:0; z-index:40; touch-action:none; display:none; }
167
+ .ocw-cobrowse-canvas.active { display:block; cursor:crosshair; }
168
+ .ocw-cobrowse-toolbar { position:absolute; top:8px; right:8px; z-index:41; display:none; gap:6px; background:rgba(255,255,255,.92); border-radius:999px; padding:5px 8px; box-shadow:0 2px 10px rgba(0,0,0,.12); }
169
+ .ocw-cobrowse-toolbar.active { display:flex; align-items:center; }
170
+ .ocw-cobrowse-swatch { width:18px; height:18px; border-radius:50%; border:2px solid transparent; cursor:pointer; padding:0; }
171
+ .ocw-cobrowse-swatch.sel { border-color:#1c1b1a; }
172
+ .ocw-cobrowse-clear { background:none; border:1px solid #e3ded7; border-radius:999px; font-size:11px; padding:3px 8px; cursor:pointer; color:var(--ocw-mut); }
173
+ .ocw-cobrowse-clear:hover { border-color:#e74c3c; color:#e74c3c; }
174
+ .ocw-cobrowse-hint { position:absolute; bottom:8px; left:8px; z-index:41; font-size:11px; color:var(--ocw-mut); background:rgba(255,255,255,.9); border-radius:8px; padding:3px 8px; display:none; }
175
+ .ocw-cobrowse-hint.active { display:block; }
176
+ .ocw-chatlist-btn { background:none; border:none; cursor:pointer; font-size:18px; color:var(--ocw-mut); padding:4px 6px; border-radius:8px; }
177
+ .ocw-chatlist-btn:hover { background:var(--ocw-bg); }
178
+ .ocw-chatlist-panel { position:absolute; inset:0; z-index:50; background:var(--ocw-card); display:flex; flex-direction:column; transform:translateX(100%); transition:transform .2s ease; }
179
+ .ocw-chatlist-panel.open { transform:translateX(0); }
180
+ .ocw-chatlist-head { display:flex; align-items:center; gap:10px; padding:12px 14px; border-bottom:1px solid var(--ocw-line); }
181
+ .ocw-chatlist-back { background:none; border:none; cursor:pointer; font-size:18px; color:var(--ocw-ink); padding:2px 6px; }
182
+ .ocw-chatlist-title { font-weight:700; font-size:15px; flex:1; }
183
+ .ocw-chatlist-body { flex:1; overflow-y:auto; }
184
+ .ocw-chatlist-empty { padding:40px 20px; text-align:center; color:var(--ocw-mut); font-size:13px; }
185
+ .ocw-chatlist-item { display:flex; align-items:center; gap:10px; padding:12px 14px; border-bottom:1px solid var(--ocw-line); cursor:pointer; background:none; border-left:none; border-right:none; width:100%; text-align:left; }
186
+ .ocw-chatlist-item:hover { background:var(--ocw-bg); }
187
+ .ocw-chatlist-item.active { background:var(--ocw-bg); }
188
+ .ocw-chatlist-ic { width:36px; height:36px; border-radius:50%; background:var(--ocw-bg); display:flex; align-items:center; justify-content:center; font-size:16px; flex:none; }
189
+ .ocw-chatlist-main { flex:1; min-width:0; }
190
+ .ocw-chatlist-name { font-weight:600; font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
191
+ .ocw-chatlist-state { font-size:11px; color:var(--ocw-mut); margin-top:2px; }
192
+ .ocw-chatlist-time { font-size:11px; color:var(--ocw-mut); flex:none; }
193
+ .ocw-translate-btn { position:absolute; bottom:2px; right:-26px; background:#fff; border:1px solid #e3ded7; border-radius:50%; width:22px; height:22px; font-size:11px; cursor:pointer; color:var(--ocw-mut); display:flex; align-items:center; justify-content:center; opacity:0; transition:opacity .15s; padding:0; }
194
+ .ocw-row.theirs .ocw-translate-btn { right:auto; left:-26px; }
195
+ .ocw-bubble-wrap:hover .ocw-translate-btn { opacity:1; }
196
+ .ocw-translated-tag { font-size:10px; color:var(--ocw-mut); margin-top:2px; }
197
+ `
198
+
199
+ function injectStyles(): void {
200
+ if (typeof document === 'undefined' || document.getElementById(STYLE_ID)) return
201
+ const s = document.createElement('style'); s.id = STYLE_ID; s.textContent = CSS; document.head.appendChild(s)
202
+ }
203
+
204
+ function el<K extends keyof HTMLElementTagNameMap>(tag: K, cls?: string, text?: string): HTMLElementTagNameMap[K] {
205
+ const n = document.createElement(tag); if (cls) n.className = cls; if (text !== undefined) n.textContent = text; return n
206
+ }
207
+ function fmtTime(ts: number): string {
208
+ try { return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) } catch { return '' }
209
+ }
210
+ function timeAgo(ts: number): string {
211
+ const sec = Math.floor((Date.now() - ts) / 1000)
212
+ if (sec < 60) return 'now'
213
+ if (sec < 3600) return `${Math.floor(sec / 60)}m`
214
+ if (sec < 86400) return `${Math.floor(sec / 3600)}h`
215
+ return `${Math.floor(sec / 86400)}d`
216
+ }
217
+ function contentText(c: MessageContent): string {
218
+ switch (c.kind) {
219
+ case 'text': return c.text
220
+ case 'system': return typeof c.data?.['message'] === 'string' ? String(c.data['message']) : c.event
221
+ case 'card': return [c.title, c.body].filter(Boolean).join(' โ€” ')
222
+ case 'attachment': return c.name ?? c.url
223
+ case 'form': return c.prompt
224
+ case 'appointment': return `๐Ÿ“… ${c.title} โ€” ${new Date(c.startIso).toLocaleString()}`
225
+ }
226
+ }
227
+
228
+ /** Renders a ChatStore into a host element in the Image-1 layout: header โ†’
229
+ * subject card โ†’ action chips โ†’ chat โ†’ quick replies โ†’ input. Self-injects its
230
+ * stylesheet so it looks right wherever it mounts. Accent comes from the
231
+ * dashboard-authored profile theme (via the manifest), falling back to config. */
232
+ export class Renderer {
233
+ private readonly scroll: HTMLElement
234
+ private readonly chips: HTMLElement
235
+ private readonly typing: HTMLElement
236
+ private readonly quick: HTMLElement
237
+ private readonly formHost: HTMLElement
238
+ private readonly csatPanel: HTMLElement
239
+ private readonly offlinePanel: HTMLElement
240
+ private readonly footer: HTMLElement
241
+ private readonly input: HTMLTextAreaElement
242
+ private readonly subjectCard: HTMLElement
243
+ private readonly e2eBadge: HTMLElement
244
+ private readonly statusBadge: HTMLElement
245
+ private readonly headerName: HTMLElement
246
+ private readonly connStatus: HTMLElement
247
+ private typingTimer: ReturnType<typeof setTimeout> | null = null
248
+ private csatSubmitted = false
249
+
250
+ // โ”€โ”€ Co-browsing (shared whiteboard) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
251
+ private readonly cobrowseBtn: HTMLButtonElement
252
+ private readonly cobrowseCanvas: HTMLCanvasElement
253
+ private readonly cobrowseToolbar: HTMLElement
254
+ private readonly cobrowseHint: HTMLElement
255
+ private cobrowseActive = false
256
+ private cobrowseColor = '#f5713c'
257
+ private lastAnnotationVersion = -1
258
+ private storeRef: ChatStore | null = null
259
+ // โ”€โ”€ Live translation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€-
260
+ private readonly translationCache = new Map<string, string>()
261
+ private readonly showingTranslation = new Set<string>()
262
+
263
+ // โ”€โ”€ Multi-subject chat list (e.g. marketplace: one thread per listing) โ”€โ”€
264
+ private readonly chatListPanel: HTMLElement
265
+ private readonly chatListBody: HTMLElement
266
+ private chatListBtn: HTMLButtonElement | null = null
267
+ private activeChatId: string | null = null
268
+
269
+ constructor(
270
+ private readonly root: HTMLElement,
271
+ private readonly me: string,
272
+ private readonly h: RendererHandlers,
273
+ private readonly cfg: WidgetConfig = {},
274
+ ) {
275
+ injectStyles()
276
+ root.classList.add('ocw')
277
+ if (cfg.accent) root.style.setProperty('--ocw-accent', cfg.accent)
278
+
279
+ // Header
280
+ const head = el('div', 'ocw-head')
281
+ head.append(el('div', 'ocw-avatar', '๐Ÿง‘'))
282
+ const hm = el('div', 'ocw-head-main')
283
+ this.headerName = el('div', 'ocw-head-name', cfg.subject?.ownerLabel ?? cfg.subject?.title ?? '')
284
+ hm.append(this.headerName)
285
+ if (cfg.subject?.subtitle) hm.append(el('div', 'ocw-head-meta', cfg.subject.subtitle))
286
+ head.append(hm)
287
+ this.statusBadge = el('span', 'ocw-badge', cfg.subject?.status ?? '')
288
+ if (!cfg.subject?.status) this.statusBadge.style.display = 'none'
289
+ head.append(this.statusBadge)
290
+ this.e2eBadge = el('span', 'ocw-e2e', '๐Ÿ”’ E2E'); this.e2eBadge.style.display = 'none'; head.append(this.e2eBadge)
291
+ this.connStatus = el('span', 'ocw-conn-status'); this.connStatus.style.display = 'none'; head.append(this.connStatus)
292
+ this.cobrowseBtn = el('button', 'ocw-menu ocw-cobrowse-btn', '๐Ÿ–') as HTMLButtonElement
293
+ this.cobrowseBtn.title = 'Shared whiteboard โ€” draw to point things out together'
294
+ this.cobrowseBtn.addEventListener('click', () => { this.cobrowseActive = !this.cobrowseActive; this.updateCobrowseUI() })
295
+ head.append(this.cobrowseBtn)
296
+ if (this.h.onListChats) {
297
+ this.chatListBtn = el('button', 'ocw-chatlist-btn', '๐Ÿ’ฌ') as HTMLButtonElement
298
+ this.chatListBtn.title = 'Your conversations'
299
+ this.chatListBtn.addEventListener('click', () => this.openChatList())
300
+ head.append(this.chatListBtn)
301
+ }
302
+ head.append(el('button', 'ocw-menu', 'โ‹ฏ'))
303
+
304
+ // Action chips (filled in render)
305
+ this.chips = el('div', 'ocw-chiprow')
306
+
307
+ // Scroll area with optional subject card + messages
308
+ this.scroll = el('div', 'ocw-scroll')
309
+ this.subjectCard = el('div', 'ocw-subject')
310
+ this.typing = el('div', 'ocw-typing')
311
+ this.quick = el('div', 'ocw-quick')
312
+ for (const q of cfg.quickReplies ?? []) {
313
+ const b = el('button', undefined, q); b.addEventListener('click', () => this.h.onSend(q)); this.quick.append(b)
314
+ }
315
+
316
+ // Input
317
+ this.formHost = el('div', 'ocw-form-host')
318
+ this.csatPanel = el('div', 'ocw-csat'); this.csatPanel.style.display = 'none'
319
+ this.offlinePanel = el('div', 'ocw-offline'); this.offlinePanel.style.display = 'none'
320
+ this.input = el('textarea', undefined); this.input.rows = 1; this.input.placeholder = 'Messageโ€ฆ'
321
+ const sendBtn = el('button', 'ocw-sendbtn', 'โžค')
322
+ sendBtn.addEventListener('click', () => this.flushSend())
323
+ this.input.addEventListener('keydown', (e) => {
324
+ // Guard against IME composition (Korean/Japanese/Chinese input): while
325
+ // the user is selecting a candidate from the IME's suggestion list,
326
+ // pressing Enter to CONFIRM the candidate also fires a keydown with
327
+ // key === 'Enter'. Without this check, that confirmation keystroke was
328
+ // being treated as "send the message" โ€” firing early with a partial
329
+ // composition, and then firing again on the real Enter press with
330
+ // whatever text was left, producing two bubbles for one message
331
+ // (e.g. typing "์Œ์‹" sends "์Œ์‹" then "์‹").
332
+ // e.isComposing covers most browsers; keyCode 229 is the long-standing
333
+ // fallback for browsers/IMEs that don't set isComposing reliably.
334
+ if (e.isComposing || e.keyCode === 229) return
335
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.flushSend() } else this.signalTyping()
336
+ })
337
+
338
+ const attachBtn = el('button', 'ocw-attach', '๐Ÿ“Ž'); attachBtn.title = 'Attach image or file'
339
+ const fileInput = document.createElement('input'); fileInput.type = 'file'
340
+ fileInput.accept = 'image/*,.pdf,.txt,.doc,.docx'; fileInput.style.display = 'none'
341
+ attachBtn.addEventListener('click', () => fileInput.click())
342
+ fileInput.addEventListener('change', () => { if (fileInput.files?.[0] && this.h.onAttach) this.h.onAttach(fileInput.files[0]); fileInput.value = '' })
343
+
344
+ const inputRow = el('div', 'ocw-input'); inputRow.append(attachBtn, fileInput, this.input, sendBtn)
345
+
346
+ const footer = el('div', 'ocw-footer')
347
+ footer.innerHTML = 'Powered by <a href="https://relay.paramms.com" target="_blank" rel="noopener">Relay</a>'
348
+ this.footer = footer
349
+
350
+ root.append(head, this.chips, this.scroll, this.typing, this.quick, this.formHost, this.csatPanel, this.offlinePanel, inputRow, this.footer)
351
+
352
+ // Co-browsing overlay: a freehand canvas layered over the whole widget.
353
+ this.cobrowseCanvas = el('canvas', 'ocw-cobrowse-canvas') as HTMLCanvasElement
354
+ this.cobrowseToolbar = el('div', 'ocw-cobrowse-toolbar')
355
+ for (const c of ['#f5713c', '#1c1b1a', '#2563eb', '#16a34a', '#dc2626']) {
356
+ const sw = el('button', 'ocw-cobrowse-swatch') as HTMLButtonElement
357
+ sw.style.background = c
358
+ sw.type = 'button'
359
+ if (c === this.cobrowseColor) sw.classList.add('sel')
360
+ sw.addEventListener('click', () => {
361
+ this.cobrowseColor = c
362
+ for (const n of this.cobrowseToolbar.querySelectorAll('.ocw-cobrowse-swatch')) n.classList.remove('sel')
363
+ sw.classList.add('sel')
364
+ })
365
+ this.cobrowseToolbar.append(sw)
366
+ }
367
+ const cobrowseClear = el('button', 'ocw-cobrowse-clear', 'Clear')
368
+ cobrowseClear.addEventListener('click', () => this.h.onAnnotateClear?.())
369
+ this.cobrowseToolbar.append(cobrowseClear)
370
+ this.cobrowseHint = el('div', 'ocw-cobrowse-hint', '๐Ÿ– Draw to point things out โ€” visible to both sides')
371
+ root.append(this.cobrowseCanvas, this.cobrowseToolbar, this.cobrowseHint)
372
+ this.bindCobrowsePointerEvents()
373
+
374
+ // Multi-subject chat list overlay โ€” slides in from the right, same
375
+ // layer as the co-browse canvas. Built once; populated on open.
376
+ this.chatListPanel = el('div', 'ocw-chatlist-panel')
377
+ const clHead = el('div', 'ocw-chatlist-head')
378
+ const clBack = el('button', 'ocw-chatlist-back', 'โ†')
379
+ clBack.addEventListener('click', () => this.closeChatList())
380
+ clHead.append(clBack, el('div', 'ocw-chatlist-title', 'Your conversations'))
381
+ this.chatListBody = el('div', 'ocw-chatlist-body')
382
+ this.chatListPanel.append(clHead, this.chatListBody)
383
+ root.append(this.chatListPanel)
384
+ }
385
+
386
+ private openChatList(): void {
387
+ this.chatListPanel.classList.add('open')
388
+ this.chatListBody.replaceChildren(el('div', 'ocw-chatlist-empty', 'Loadingโ€ฆ'))
389
+ this.h.onListChats?.().then((entries) => {
390
+ this.chatListBody.replaceChildren()
391
+ if (!entries.length) {
392
+ this.chatListBody.append(el('div', 'ocw-chatlist-empty', 'No conversations yet.'))
393
+ return
394
+ }
395
+ for (const entry of entries) {
396
+ const item = el('button', `ocw-chatlist-item${entry.id === this.activeChatId ? ' active' : ''}`)
397
+ item.append(el('div', 'ocw-chatlist-ic', '๐Ÿ’ฌ'))
398
+ const main = el('div', 'ocw-chatlist-main')
399
+ main.append(el('div', 'ocw-chatlist-name', entry.subjectTitle ?? 'General enquiry'))
400
+ main.append(el('div', 'ocw-chatlist-state', entry.state))
401
+ item.append(main)
402
+ item.append(el('div', 'ocw-chatlist-time', timeAgo(entry.updatedAt)))
403
+ item.addEventListener('click', () => {
404
+ this.activeChatId = entry.id
405
+ this.closeChatList()
406
+ this.h.onSwitchChat?.(entry)
407
+ })
408
+ this.chatListBody.append(item)
409
+ }
410
+ }).catch(() => {
411
+ this.chatListBody.replaceChildren(el('div', 'ocw-chatlist-empty', 'Could not load your conversations.'))
412
+ })
413
+ }
414
+
415
+ private closeChatList(): void {
416
+ this.chatListPanel.classList.remove('open')
417
+ }
418
+
419
+ private flushSend(): void {
420
+ const text = this.input.value.trim()
421
+ if (!text) return
422
+ this.input.value = ''
423
+ this.h.onTyping(false)
424
+ this.h.onSend(text)
425
+ }
426
+ private signalTyping(): void {
427
+ this.h.onTyping(true)
428
+ if (this.typingTimer) clearTimeout(this.typingTimer)
429
+ this.typingTimer = setTimeout(() => this.h.onTyping(false), 2000)
430
+ }
431
+
432
+ render(store: ChatStore): void {
433
+ this.storeRef = store
434
+ // Co-browsing toggle is only meaningful for subject-anchored conversations.
435
+ this.cobrowseBtn.classList.toggle('show', !!store.subject)
436
+ if (!store.subject && this.cobrowseActive) { this.cobrowseActive = false; this.updateCobrowseUI() }
437
+ if (this.cobrowseActive && store.annotationVersion !== this.lastAnnotationVersion) {
438
+ this.lastAnnotationVersion = store.annotationVersion
439
+ this.resizeCobrowseCanvas()
440
+ this.redrawCobrowse()
441
+ }
442
+ if (store.accent) this.root.style.setProperty('--ocw-accent', store.accent)
443
+ this.e2eBadge.style.display = store.e2e ? 'inline-flex' : 'none'
444
+ this.buildSubjectCard(store)
445
+ // Header: when a subject is attached, show ownerLabel ("Seller", "Host")
446
+ // or nothing โ€” the subject card below carries the identity.
447
+ // Without a subject, the header is already set to cfg.subject?.ownerLabel
448
+ // or "Chat" from the constructor โ€” don't overwrite it with the domain name
449
+ // which would duplicate the subject card title or clutter a plain chat.
450
+ if (store.subject) {
451
+ const ownerLabel = this.cfg.subject?.ownerLabel
452
+ if (ownerLabel) this.headerName.textContent = ownerLabel
453
+ // else leave constructor default ("Chat")
454
+ }
455
+ // Without a subject: leave header as-is (set once in constructor)
456
+
457
+ // Action chips from the manifest (filtered by state in the store)
458
+ this.chips.replaceChildren()
459
+ const actions = store.visibleActions()
460
+ this.chips.style.display = actions.length ? 'flex' : 'none'
461
+ for (const a of actions) this.chips.append(this.chipEl(a))
462
+
463
+ // Messages
464
+ // Preserve scroll anchor when history is prepended: capture height before
465
+ // replaceChildren so we can restore relative position after.
466
+ const prevScrollHeight = this.scroll.scrollHeight
467
+ const prevScrollTop = this.scroll.scrollTop
468
+
469
+ this.scroll.replaceChildren()
470
+ if (this.subjectCard.childNodes.length) this.scroll.append(this.subjectCard)
471
+ if (store.hasMoreHistory && this.h.onLoadMore) {
472
+ const btn = el('button', 'ocw-load-more', 'โ†‘ Load earlier messages')
473
+ btn.addEventListener('click', () => this.h.onLoadMore!())
474
+ this.scroll.append(btn)
475
+ }
476
+ let maxOther = 0
477
+ for (const m of store.messages()) {
478
+ this.scroll.append(this.messageEl(m, store))
479
+ if (m.senderId !== this.me && m.seq > maxOther) maxOther = m.seq
480
+ }
481
+ // Auto-scroll to bottom only for new messages; restore anchor when history was prepended.
482
+ if (prevScrollTop > 20) {
483
+ this.scroll.scrollTop = this.scroll.scrollHeight - prevScrollHeight + prevScrollTop
484
+ } else {
485
+ this.scroll.scrollTop = this.scroll.scrollHeight
486
+ }
487
+ if (maxOther > 0) this.h.onReadUpTo(maxOther)
488
+
489
+ this.typing.textContent = store.typing.size > 0 ? 'typingโ€ฆ' : ''
490
+ this.footer.style.display = store.whiteLabel ? 'none' : 'block'
491
+
492
+ // Offline mode: show form instead of chat input
493
+ if (store.offline) {
494
+ if (this.offlinePanel.style.display === 'none') this.buildOfflinePanel(store.offlineMessage)
495
+ this.offlinePanel.style.display = 'block'
496
+ ;(this.root.querySelector('.ocw-input') as HTMLElement | null)?.style.setProperty('display', 'none')
497
+ } else {
498
+ this.offlinePanel.style.display = 'none'
499
+ ;(this.root.querySelector('.ocw-input') as HTMLElement | null)?.style.removeProperty('display')
500
+ }
501
+
502
+ // CSAT: show star-rating panel when conversation reaches a terminal state
503
+ // and the user hasn't yet rated. Terminal states are heuristic: 'resolved',
504
+ // 'closed', 'sold', 'issued', 'checked_out'. The panel self-dismisses on submit.
505
+ const terminalStates = ['resolved', 'closed', 'sold', 'issued', 'checked_out']
506
+ if (this.h.onCsat && !this.csatSubmitted && terminalStates.includes(store.state) && store.messages().length > 0) {
507
+ if (this.csatPanel.style.display === 'none') this.buildCsatPanel()
508
+ this.csatPanel.style.display = 'block'
509
+ }
510
+ }
511
+
512
+ setConnStatus(status: 'connecting' | 'open' | 'reconnecting'): void {
513
+ if (status === 'open') { this.connStatus.style.display = 'none'; return }
514
+ this.connStatus.style.display = ''
515
+ this.connStatus.className = `ocw-conn-status${status === 'reconnecting' ? ' warn' : ''}`
516
+ this.connStatus.textContent = status === 'reconnecting' ? 'โ†ป reconnectingโ€ฆ' : 'โ— connectingโ€ฆ'
517
+ }
518
+
519
+ private buildOfflinePanel(offlineMessage?: string): void {
520
+ this.offlinePanel.replaceChildren()
521
+ this.offlinePanel.append(el('div', 'ocw-offline-icon', '๐ŸŒ™'))
522
+ this.offlinePanel.append(el('div', 'ocw-offline-title', "We're offline right now"))
523
+ this.offlinePanel.append(el('div', 'ocw-offline-msg', offlineMessage || "Leave your details and we'll get back to you soon."))
524
+ const form = el('div', 'ocw-offline-form')
525
+ const nameIn = el('input', 'ocw-offline-input') as HTMLInputElement; nameIn.placeholder = 'Your name'; nameIn.type = 'text'
526
+ const emailIn = el('input', 'ocw-offline-input') as HTMLInputElement; emailIn.placeholder = 'Your email'; emailIn.type = 'email'
527
+ const msgIn = el('textarea', 'ocw-offline-input') as HTMLTextAreaElement; msgIn.placeholder = 'Your message'; msgIn.rows = 3
528
+ const submit = el('button', 'ocw-offline-submit', 'Send message')
529
+ submit.addEventListener('click', () => {
530
+ if (!emailIn.value.trim() || !msgIn.value.trim()) return
531
+ // Post as a regular message (offline form is stored as a conversation once submitted)
532
+ this.h.onSend(`[Offline form]\nName: ${nameIn.value || 'Anonymous'}\nEmail: ${emailIn.value}\nMessage: ${msgIn.value}`)
533
+ this.offlinePanel.replaceChildren(el('div', 'ocw-offline-thanks', 'โœ“ Message sent! We\'ll reply to your email.'))
534
+ })
535
+ form.append(nameIn, emailIn, msgIn, submit)
536
+ this.offlinePanel.append(form)
537
+ }
538
+
539
+ private buildCsatPanel(): void {
540
+ this.csatPanel.replaceChildren()
541
+ this.csatPanel.append(el('div', 'ocw-csat-title', 'How did we do?'))
542
+ const stars = el('div', 'ocw-csat-stars')
543
+ const btns: HTMLButtonElement[] = []
544
+ for (let i = 1; i <= 5; i++) {
545
+ const b = el('button', 'ocw-csat-star', 'โ˜…')
546
+ b.dataset['score'] = String(i)
547
+ b.addEventListener('mouseenter', () => btns.forEach((bb, idx) => bb.classList.toggle('lit', idx < i)))
548
+ b.addEventListener('mouseleave', () => btns.forEach(bb => bb.classList.remove('lit')))
549
+ b.addEventListener('click', () => {
550
+ this.csatSubmitted = true
551
+ this.csatPanel.replaceChildren(el('div', 'ocw-csat-done', `Thanks for your ${i}โ˜… rating!`))
552
+ this.h.onCsat?.(i)
553
+ })
554
+ btns.push(b); stars.append(b)
555
+ }
556
+ this.csatPanel.append(stars)
557
+ }
558
+
559
+ private subjectBuilt = false
560
+ /** Subject card from the server's Subject entity (per chatroom), with the
561
+ * mount config as a fallback. Built once when data first arrives. */
562
+ // โ”€โ”€ Co-browsing (shared whiteboard) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
563
+ private updateCobrowseUI(): void {
564
+ this.cobrowseCanvas.classList.toggle('active', this.cobrowseActive)
565
+ this.cobrowseToolbar.classList.toggle('active', this.cobrowseActive)
566
+ this.cobrowseHint.classList.toggle('active', this.cobrowseActive)
567
+ this.cobrowseBtn.classList.toggle('on', this.cobrowseActive)
568
+ if (this.cobrowseActive) {
569
+ this.resizeCobrowseCanvas()
570
+ this.redrawCobrowse()
571
+ }
572
+ }
573
+
574
+ private resizeCobrowseCanvas(): void {
575
+ this.cobrowseCanvas.width = this.root.clientWidth || 1
576
+ this.cobrowseCanvas.height = this.root.clientHeight || 1
577
+ }
578
+
579
+ /** Draw a stroke whose points are normalized to 0..1, scaled to the current
580
+ * canvas size โ€” so strokes line up across different viewport sizes. */
581
+ private drawStroke(stroke: { points: { x: number; y: number }[]; color: string; width: number }): void {
582
+ const ctx = this.cobrowseCanvas.getContext('2d')
583
+ if (!ctx || stroke.points.length < 2) return
584
+ const w = this.cobrowseCanvas.width, h = this.cobrowseCanvas.height
585
+ ctx.strokeStyle = stroke.color
586
+ ctx.lineWidth = Math.max(1, stroke.width * Math.min(w, h))
587
+ ctx.lineJoin = 'round'
588
+ ctx.lineCap = 'round'
589
+ ctx.beginPath()
590
+ ctx.moveTo(stroke.points[0]!.x * w, stroke.points[0]!.y * h)
591
+ for (const p of stroke.points.slice(1)) ctx.lineTo(p.x * w, p.y * h)
592
+ ctx.stroke()
593
+ }
594
+
595
+ private redrawCobrowse(): void {
596
+ const ctx = this.cobrowseCanvas.getContext('2d')
597
+ if (!ctx) return
598
+ ctx.clearRect(0, 0, this.cobrowseCanvas.width, this.cobrowseCanvas.height)
599
+ for (const s of this.storeRef?.annotations ?? []) this.drawStroke(s)
600
+ }
601
+
602
+ private bindCobrowsePointerEvents(): void {
603
+ let drawing = false
604
+ let current: { x: number; y: number }[] = []
605
+ const posFromEvent = (e: PointerEvent): { x: number; y: number } => {
606
+ const rect = this.cobrowseCanvas.getBoundingClientRect()
607
+ const x = rect.width > 0 ? (e.clientX - rect.left) / rect.width : 0
608
+ const y = rect.height > 0 ? (e.clientY - rect.top) / rect.height : 0
609
+ return { x: Math.min(1, Math.max(0, x)), y: Math.min(1, Math.max(0, y)) }
610
+ }
611
+ const STROKE_WIDTH = 0.006 // normalized โ€” ~6px on a 1000px-wide canvas
612
+ this.cobrowseCanvas.addEventListener('pointerdown', (e) => {
613
+ if (!this.cobrowseActive) return
614
+ drawing = true
615
+ current = [posFromEvent(e)]
616
+ this.cobrowseCanvas.setPointerCapture(e.pointerId)
617
+ })
618
+ this.cobrowseCanvas.addEventListener('pointermove', (e) => {
619
+ if (!drawing) return
620
+ current.push(posFromEvent(e))
621
+ this.redrawCobrowse()
622
+ this.drawStroke({ points: current, color: this.cobrowseColor, width: STROKE_WIDTH })
623
+ })
624
+ const finish = (e: PointerEvent): void => {
625
+ if (!drawing) return
626
+ drawing = false
627
+ if (current.length > 1) {
628
+ this.h.onAnnotate?.({ id: `an_${Date.now()}_${Math.random().toString(36).slice(2)}`, points: current, color: this.cobrowseColor, width: STROKE_WIDTH })
629
+ }
630
+ current = []
631
+ try { this.cobrowseCanvas.releasePointerCapture(e.pointerId) } catch { /* not captured */ }
632
+ }
633
+ this.cobrowseCanvas.addEventListener('pointerup', finish)
634
+ this.cobrowseCanvas.addEventListener('pointercancel', finish)
635
+ }
636
+
637
+ private buildSubjectCard(store: ChatStore): void {
638
+ if (this.subjectBuilt) return
639
+ const s = store.subject
640
+ const cfg = this.cfg.subject
641
+ // Only show the subject card when there is actual subject data (from the
642
+ // server) or an explicit subject config passed by the embedder (title,
643
+ // tags, status). Never fall back to store.name โ€” that's the domain/profile
644
+ // name and is already shown in the header; rendering it here as well is
645
+ // what caused the duplication seen in the Hotel front desk screenshot.
646
+ const title = s?.title ?? cfg?.title
647
+ if (!title) return
648
+ this.subjectBuilt = true
649
+ this.subjectCard.replaceChildren()
650
+ this.subjectCard.append(el('div', 'ocw-subject-title', title))
651
+ if (cfg?.subtitle) this.subjectCard.append(el('div', 'ocw-subject-sub', cfg.subtitle))
652
+ const tags = el('div', 'ocw-tags')
653
+ if (s) for (const [k, v] of Object.entries(s.fields)) tags.append(el('span', 'ocw-tag', `${k}: ${v}`))
654
+ else for (const t of cfg?.tags ?? []) tags.append(el('span', 'ocw-tag', t))
655
+ if (tags.childNodes.length) this.subjectCard.append(tags)
656
+ const status = s?.state ?? cfg?.status
657
+ if (status) { this.statusBadge.textContent = status; this.statusBadge.style.display = 'inline-flex' }
658
+ }
659
+
660
+ private chipEl(a: ManifestAction): HTMLButtonElement {
661
+ const btn = el('button', 'ocw-chip', a.icon ? `${a.icon} ${a.label}` : a.label)
662
+ btn.dataset['actionId'] = a.id
663
+ btn.addEventListener('click', async () => {
664
+ if (a.confirm && !(await this.confirm(a.label))) return
665
+ if (a.input?.length) this.openForm(a)
666
+ else this.h.onInvoke(a.id)
667
+ })
668
+ return btn
669
+ }
670
+
671
+ /** In-widget confirmation modal (replaces window.confirm). */
672
+ private confirm(label: string): Promise<boolean> {
673
+ return new Promise((resolve) => {
674
+ const overlay = el('div', 'ocw-modal')
675
+ const card = el('div', 'ocw-modal-card')
676
+ card.append(el('div', 'ocw-modal-title', label))
677
+ card.append(el('div', 'ocw-modal-body', `Confirm โ€œ${label}โ€?`))
678
+ const row = el('div', 'ocw-modal-actions')
679
+ const cancel = el('button', 'ocw-modal-cancel', 'Cancel')
680
+ const ok = el('button', 'ocw-modal-ok', 'Confirm')
681
+ const close = (v: boolean) => { overlay.remove(); resolve(v) }
682
+ cancel.addEventListener('click', () => close(false))
683
+ ok.addEventListener('click', () => close(true))
684
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false) })
685
+ row.append(cancel, ok); card.append(row); overlay.append(card)
686
+ this.root.append(overlay)
687
+ ok.focus()
688
+ })
689
+ }
690
+
691
+ /** Inline form for a form-effect action: typed inputs (date picker, number,
692
+ * text) rendered above the composer โ€” no browser prompts. */
693
+ private openForm(a: ManifestAction): void {
694
+ this.formHost.replaceChildren()
695
+ const panel = el('div', 'ocw-form')
696
+ panel.append(el('div', 'ocw-form-title', a.icon ? `${a.icon} ${a.label}` : a.label))
697
+ const inputs = new Map<string, HTMLInputElement>()
698
+ for (const f of a.input ?? []) {
699
+ const row = el('label', 'ocw-form-row'); row.append(el('span', 'ocw-form-lbl', f.label))
700
+ if (f.type === 'select' && f.options?.length) {
701
+ const sel = el('select', 'ocw-form-input')
702
+ if (!f.required) sel.append(el('option', undefined, 'โ€” select โ€”'))
703
+ for (const opt of f.options) { const o = el('option'); o.value = opt; o.textContent = opt; sel.append(o) }
704
+ if (f.required) sel.required = true
705
+ row.append(sel)
706
+ inputs.set(f.name, sel as unknown as HTMLInputElement)
707
+ } else {
708
+ const inp = el('input', 'ocw-form-input')
709
+ inp.type = f.type === 'number' ? 'number' : f.type === 'date' ? 'datetime-local' : 'text'
710
+ if (f.required) inp.required = true
711
+ row.append(inp); inputs.set(f.name, inp)
712
+ }
713
+ panel.append(row)
714
+ }
715
+ const actions = el('div', 'ocw-form-actions')
716
+ const cancel = el('button', 'ocw-form-cancel', 'Cancel')
717
+ const submit = el('button', 'ocw-form-submit', 'Send')
718
+ cancel.addEventListener('click', () => this.formHost.replaceChildren())
719
+ submit.addEventListener('click', () => {
720
+ const out: Record<string, unknown> = {}
721
+ for (const [name, inp] of inputs) {
722
+ if (inp.required && !inp.value) { inp.style.borderColor = '#e5484d'; return }
723
+ out[name] = inp.type === 'number' ? Number(inp.value) : inp.value
724
+ }
725
+ this.formHost.replaceChildren()
726
+ this.h.onInvoke(a.id, out)
727
+ })
728
+ actions.append(cancel, submit); panel.append(actions)
729
+ this.formHost.append(panel)
730
+ inputs.values().next().value?.focus()
731
+ }
732
+
733
+ private messageEl(m: RenderMessage, store: ChatStore): HTMLElement {
734
+ if (m.senderRole === 'system') {
735
+ const sys = el('div', 'ocw-sys'); sys.textContent = m.deletedAt ? 'message deleted' : contentText(m.content); return sys
736
+ }
737
+ const mine = m.senderId === this.me
738
+ const isNote = !!m.internal
739
+ const row = el('div', `ocw-row ${isNote ? 'ocw-note mine' : mine ? 'mine' : 'theirs'} ${m.senderRole === 'bot' ? 'ocw-bot' : ''}`)
740
+ if (!mine && !isNote) row.append(el('div', 'ocw-dot', m.senderRole === 'bot' ? '๐Ÿค–' : '๐Ÿง‘'))
741
+ const col = el('div')
742
+ const bubbleWrap = el('div', 'ocw-bubble-wrap')
743
+ // Reply-to context if present
744
+ if (m.replyToId) {
745
+ const replyCtx = el('div', 'ocw-reply-to', 'โ†ฉ replying to a message')
746
+ replyCtx.style.cssText = 'font-size:11px;color:var(--ocw-mut);margin-bottom:2px;font-style:italic'
747
+ col.append(replyCtx)
748
+ }
749
+ const bubble = el('div', 'ocw-bubble')
750
+ let textNode: Text | null = null
751
+ if (m.deletedAt) bubble.append(el('span', 'ocw-deleted', 'message deleted'))
752
+ else if (m.content.kind === 'attachment') {
753
+ const c = m.content
754
+ if (c.mime?.startsWith('image/')) {
755
+ const img = document.createElement('img')
756
+ img.src = c.url; img.alt = c.name ?? 'image'
757
+ img.style.cssText = 'max-width:220px;max-height:160px;border-radius:10px;display:block;cursor:pointer'
758
+ img.addEventListener('click', () => window.open(c.url, '_blank'))
759
+ bubble.append(img)
760
+ } else {
761
+ const a = document.createElement('a')
762
+ a.href = c.url; a.target = '_blank'; a.rel = 'noopener'
763
+ a.style.cssText = 'display:flex;align-items:center;gap:8px;color:inherit;text-decoration:none'
764
+ a.append(el('span', undefined, '๐Ÿ“„'), el('span', undefined, c.name ?? 'file'))
765
+ bubble.append(a)
766
+ }
767
+ } else {
768
+ if (m.content.kind === 'appointment') {
769
+ const ap = m.content
770
+ const card = el('div', 'ocw-appt')
771
+ card.append(el('div', 'ocw-appt-title', `\u{1F4C5} ${ap.title}`))
772
+ card.append(el('div', 'ocw-appt-time', new Date(ap.startIso).toLocaleString() + ' \u2013 ' + new Date(ap.endIso).toLocaleTimeString()))
773
+ if (ap.location) card.append(el('div', 'ocw-appt-loc', `\u{1F4CD} ${ap.location}`))
774
+ if (ap.description) card.append(el('div', 'ocw-appt-desc', ap.description))
775
+ const links = el('div', 'ocw-appt-links')
776
+ const gLink = document.createElement('a'); gLink.href = ap.googleUrl; gLink.target = '_blank'; gLink.rel = 'noopener'; gLink.className = 'ocw-appt-btn'; gLink.textContent = '\u{1F4C5} Add to Google Calendar'
777
+ const iLink = document.createElement('a'); iLink.href = ap.icalUrl; iLink.download = `${ap.title}.ics`; iLink.className = 'ocw-appt-btn ocw-appt-btn-sec'; iLink.textContent = '\u{1F34E} Apple / iCal'
778
+ links.append(gLink, iLink); card.append(links); bubble.append(card)
779
+ } else {
780
+ textNode = document.createTextNode(contentText(m.content))
781
+ bubble.append(textNode)
782
+ if (m.editedAt) bubble.append(el('span', 'ocw-edited', '(edited)'))
783
+ }
784
+ }
785
+ bubbleWrap.append(bubble)
786
+
787
+ // Live translation: only for the other party's plain-text messages (not notes).
788
+ if (!mine && !isNote && this.h.onTranslate && m.content.kind === 'text' && !m.deletedAt && m.seq > 0 && textNode) {
789
+ const original = m.content.text
790
+ if (original.trim()) {
791
+ const translateBtn = el('button', 'ocw-translate-btn', '๐ŸŒ')
792
+ translateBtn.type = 'button'
793
+ translateBtn.title = 'Translate'
794
+ translateBtn.addEventListener('click', (e) => {
795
+ e.stopPropagation()
796
+ if (this.showingTranslation.has(m.id)) {
797
+ this.showingTranslation.delete(m.id)
798
+ textNode!.textContent = original
799
+ translateBtn.textContent = '๐ŸŒ'
800
+ translateBtn.title = 'Translate'
801
+ return
802
+ }
803
+ const cached = this.translationCache.get(m.id)
804
+ if (cached !== undefined) {
805
+ this.showingTranslation.add(m.id)
806
+ textNode!.textContent = cached
807
+ translateBtn.textContent = 'โ†ฉ'
808
+ translateBtn.title = 'Show original'
809
+ return
810
+ }
811
+ translateBtn.textContent = 'โณ'
812
+ void this.h.onTranslate!(original).then((result) => {
813
+ if (result === null) {
814
+ translateBtn.textContent = 'โš ๏ธ'
815
+ translateBtn.title = 'Translation unavailable'
816
+ setTimeout(() => { translateBtn.textContent = '๐ŸŒ'; translateBtn.title = 'Translate' }, 1500)
817
+ return
818
+ }
819
+ this.translationCache.set(m.id, result)
820
+ this.showingTranslation.add(m.id)
821
+ textNode!.textContent = result
822
+ translateBtn.textContent = 'โ†ฉ'
823
+ translateBtn.title = 'Show original'
824
+ })
825
+ })
826
+ bubbleWrap.append(translateBtn)
827
+ }
828
+ }
829
+ // Edit/delete context menu on own non-deleted messages
830
+ if (mine && !m.deletedAt && m.seq > 0 && (this.h.onEdit ?? this.h.onDelete)) {
831
+ const menu = el('div', 'ocw-msg-menu')
832
+ if (this.h.onEdit) {
833
+ const editBtn = el('button', undefined, 'โœ๏ธ')
834
+ editBtn.title = 'Edit'
835
+ editBtn.addEventListener('click', (e) => {
836
+ e.stopPropagation()
837
+ const text = contentText(m.content)
838
+ const newText = prompt('Edit message:', text)
839
+ if (newText !== null && newText.trim() && newText !== text) this.h.onEdit!(m.id, newText.trim())
840
+ })
841
+ menu.append(editBtn)
842
+ }
843
+ if (this.h.onDelete) {
844
+ const delBtn = el('button', 'del', '๐Ÿ—‘')
845
+ delBtn.title = 'Delete'
846
+ delBtn.addEventListener('click', (e) => { e.stopPropagation(); this.h.onDelete!(m.id) })
847
+ menu.append(delBtn)
848
+ }
849
+ bubbleWrap.append(menu)
850
+ }
851
+ col.append(bubbleWrap)
852
+
853
+ // Reactions: existing pills + add-reaction picker (hover-revealed)
854
+ if (this.h.onReact && !m.deletedAt && m.seq > 0) {
855
+ const reactWrap = el('div', 'ocw-react-wrap')
856
+ const reactRow = el('div', 'ocw-react')
857
+ // Existing reaction pills
858
+ if (m.reactions && Object.keys(m.reactions).length) {
859
+ for (const [emoji, users] of Object.entries(m.reactions)) {
860
+ const pill = el('button', `ocw-react-pill${(users as string[]).includes(this.me) ? ' mine' : ''}`, `${emoji} ${(users as string[]).length}`)
861
+ pill.addEventListener('click', () => this.h.onReact?.(m.id, emoji, (users as string[]).includes(this.me)))
862
+ reactRow.append(pill)
863
+ }
864
+ }
865
+ // Add-reaction button + picker
866
+ const addBtn = el('button', 'ocw-react-btn', '๏ผ‹')
867
+ const picker = el('div', 'ocw-react-picker')
868
+ for (const emoji of REACTION_EMOJIS) {
869
+ const pb = el('button', undefined, emoji)
870
+ pb.addEventListener('click', (e) => {
871
+ e.stopPropagation()
872
+ const alreadyReacted = m.reactions?.[emoji]?.includes(this.me as never)
873
+ this.h.onReact?.(m.id, emoji, !!alreadyReacted)
874
+ picker.style.display = 'none'
875
+ })
876
+ picker.append(pb)
877
+ }
878
+ picker.style.display = 'none'
879
+ addBtn.addEventListener('click', (e) => {
880
+ e.stopPropagation()
881
+ picker.style.display = picker.style.display === 'none' ? 'flex' : 'none'
882
+ })
883
+ document.addEventListener('click', () => { picker.style.display = 'none' }, { once: true })
884
+ reactRow.append(addBtn)
885
+ reactWrap.append(reactRow, picker)
886
+ col.append(reactWrap)
887
+ } else if (m.reactions && Object.keys(m.reactions).length) {
888
+ col.append(el('div', 'ocw-react', Object.entries(m.reactions).map(([e, u]) => `${e}${(u as string[]).length}`).join(' ')))
889
+ }
890
+ void store // suppress unused warning โ€” store available for future use
891
+
892
+ const meta = el('div', 'ocw-time ocw-meta', fmtTime(m.ts))
893
+ if (mine && m.status) { const t = el('span', 'ocw-tick', tick(m.status)); meta.append(t) }
894
+ // "Seen" indicator when agent has read past this message
895
+ if (mine && m.seq > 0 && store.lastReadByOthers >= m.seq) {
896
+ meta.append(el('span', 'ocw-seen', ' ยท Seen'))
897
+ }
898
+ col.append(meta)
899
+ row.append(col)
900
+ return row
901
+ }
902
+ }
903
+
904
+ function tick(s: NonNullable<RenderMessage['status']>): string {
905
+ switch (s) { case 'read': case 'delivered': return 'โœ“โœ“'; case 'sent': return 'โœ“'; default: return '๐Ÿ•“' }
906
+ }