@kodaris/krubble-app-components 1.0.70 → 1.0.72

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/dist/chatbot.js CHANGED
@@ -10,7 +10,10 @@ import { classMap } from 'lit/directives/class-map.js';
10
10
  import { unsafeHTML } from 'lit/directives/unsafe-html.js';
11
11
  import { repeat } from 'lit/directives/repeat.js';
12
12
  import { marked } from 'marked';
13
+ // Importing these named exports also registers their custom elements
14
+ // (kr-dialog, kr-tab-group, kr-tab, kr-text-field) used by the browse dialog.
13
15
  import { KRFilePreview } from '@kodaris/krubble-components';
16
+ import { KRHttp } from '@kodaris/krubble-http';
14
17
  /** Image MIME types the chatbot accepts (matches what Bedrock can ingest). */
15
18
  const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
16
19
  /**
@@ -33,7 +36,7 @@ const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/web
33
36
  * ## Usage
34
37
  * ```html
35
38
  * <kr-chatbot
36
- * title="AI Assistant"
39
+ * label="AI Assistant"
37
40
  * .send=${(params) => {
38
41
  * return fetch('/api/ai/chat/stream', {
39
42
  * method: 'POST',
@@ -76,16 +79,25 @@ let KRChatbot = class KRChatbot extends LitElement {
76
79
  /**
77
80
  * Callback function for sending messages. Receives the user's message and
78
81
  * current conversation history. Must return a Response with a streaming SSE body.
82
+ *
83
+ * Optional — when not set, the chatbot uses its built-in default that streams
84
+ * from the Kodaris Bedrock conversation endpoints. Set this to override.
79
85
  */
80
86
  this.send = null;
81
87
  /**
82
88
  * Callback function called when the user clears the conversation.
83
89
  * Must return a Promise — messages are only cleared if it resolves.
90
+ *
91
+ * Optional — when not set, the chatbot uses its built-in default that clears
92
+ * the Kodaris Bedrock conversation state. Set this to override.
84
93
  */
85
94
  this.clear = null;
86
95
  /**
87
96
  * Callback function called on init to load existing conversation messages.
88
97
  * Must return a Promise that resolves with an array of KRChatbotMessage.
98
+ *
99
+ * Optional — when not set, the chatbot uses its built-in default that loads
100
+ * existing messages from the Kodaris Bedrock conversation. Set this to override.
89
101
  */
90
102
  this.load = null;
91
103
  /**
@@ -94,17 +106,19 @@ let KRChatbot = class KRChatbot extends LitElement {
94
106
  */
95
107
  this.messages = [];
96
108
  /**
97
- * Header title text.
109
+ * Header label text.
98
110
  */
99
- this.title = 'Kat';
100
- /**
101
- * Header subtitle text (shown below title with a green status dot).
102
- */
103
- this.subtitle = '';
111
+ this.label = 'Kat';
104
112
  /**
105
113
  * Input placeholder text.
106
114
  */
107
115
  this.placeholder = 'Type a message...';
116
+ /**
117
+ * Skills the user can invoke by typing `/` in the composer. Selecting one
118
+ * inserts `/name ` into the message. The `/` menu shows the first 15 (filtered
119
+ * as the user types); "Browse all skills" opens a dialog with the full list.
120
+ */
121
+ this.skills = [];
108
122
  /**
109
123
  * Whether the chat panel is open. Only relevant in floating mode.
110
124
  */
@@ -115,15 +129,39 @@ let KRChatbot = class KRChatbot extends LitElement {
115
129
  * then driven via the `open()` / `close()` / `toggle()` methods.
116
130
  */
117
131
  this.hideToggle = false;
132
+ /**
133
+ * Layout mode.
134
+ * - `'floating'` (default): the chatbot is a fixed bottom-right widget with a
135
+ * toggle button, and the panel can be dragged and resized. This is the
136
+ * traditional chat-widget appearance.
137
+ * - `'inline'`: the chatbot drops its fixed positioning and fills its parent
138
+ * container (100% width/height, no border radius/shadow, no toggle, no
139
+ * drag/resize). The panel is always open. Use this to embed the chat as a
140
+ * full page or panel region.
141
+ */
142
+ this.variant = 'floating';
118
143
  // --- @state (internal) ---
119
144
  this._inputValue = '';
120
145
  this._isStreaming = false;
121
- this._statusText = '';
122
146
  this._error = '';
123
147
  /** Images staged in the composer, before the message is sent. */
124
- this._pendingAttachments = [];
148
+ this._pendingInternalFiles = [];
125
149
  /** True while a file is being dragged over the panel (shows the drop overlay). */
126
150
  this._isDraggingFile = false;
151
+ /** Whether the `/` skill menu is open above the composer. */
152
+ this._skillMenuOpened = false;
153
+ /** Current `/` query (text after the slash), used to filter the skill menu. */
154
+ this._skillQuery = '';
155
+ /** Highlighted item in the skill menu for keyboard navigation (-1 = none). */
156
+ this._skillHighlighted = 0;
157
+ /** Index of the message whose copy button just flashed "Copied" (-1 = none). */
158
+ this._copiedIndex = -1;
159
+ /** Whether the "Browse all skills" dialog is open. */
160
+ this._skillBrowseOpened = false;
161
+ /** Active tab in the browse dialog. */
162
+ this._skillBrowseSource = 'org';
163
+ /** Search text in the browse dialog. */
164
+ this._skillBrowseQuery = '';
127
165
  this._handleMouseMove = (e) => {
128
166
  if (this._isDragging) {
129
167
  let newLeft = this._dragStartLeft + (e.clientX - this._dragStartX);
@@ -185,12 +223,27 @@ let KRChatbot = class KRChatbot extends LitElement {
185
223
  // --- Lifecycle ---
186
224
  connectedCallback() {
187
225
  super.connectedCallback();
188
- if (this.load) {
189
- this.load().then((messages) => {
190
- if (messages && messages.length) {
191
- this.messages = messages;
192
- }
193
- }).catch(() => { });
226
+ // Default to loading existing messages from the Kodaris Bedrock conversation
227
+ // when the consumer hasn't provided their own `load`. The endpoint returns
228
+ // messages already in KRChatbotMessage shape ({ type, content, images }), so
229
+ // they pass straight through.
230
+ if (!this.load) {
231
+ this.load = () => KRHttp.fetch({
232
+ url: '/api/system/integration/aws/bedrock/getConversationMessages',
233
+ })
234
+ .then((r) => r.json())
235
+ .then((res) => res.data || []);
236
+ }
237
+ this.load().then((messages) => {
238
+ if (messages && messages.length) {
239
+ this.messages = messages;
240
+ }
241
+ }).catch(() => { });
242
+ // Inline mode fills its container and is always open — the floating widget's
243
+ // toggle/drag/resize state (including any persisted layout) doesn't apply.
244
+ if (this.variant === 'inline') {
245
+ this.opened = true;
246
+ return;
194
247
  }
195
248
  this._restoreState();
196
249
  }
@@ -203,10 +256,10 @@ let KRChatbot = class KRChatbot extends LitElement {
203
256
  document.removeEventListener('mousemove', this._handleMouseMove);
204
257
  document.removeEventListener('mouseup', this._handleMouseUp);
205
258
  // Release any object URLs still held by staged attachments.
206
- this._pendingAttachments.forEach((p) => URL.revokeObjectURL(p.previewUrl));
259
+ this._pendingInternalFiles.forEach((p) => URL.revokeObjectURL(p.previewUrl));
207
260
  }
208
261
  updated(changed) {
209
- if (changed.has('messages') || changed.has('_statusText')) {
262
+ if (changed.has('messages')) {
210
263
  if (!this._userHasScrolledUp && !this._isStreaming && this._messagesEl) {
211
264
  requestAnimationFrame(() => {
212
265
  if (this._messagesEl) {
@@ -222,6 +275,40 @@ let KRChatbot = class KRChatbot extends LitElement {
222
275
  }
223
276
  }, 200);
224
277
  }
278
+ // Position the fixed skill menu above the composer, following the krubble
279
+ // select-field pattern (fixed element positioned from the trigger's rect).
280
+ if (this._skillMenuOpened) {
281
+ this._positionSkillMenu();
282
+ }
283
+ // Keep the keyboard-highlighted skill scrolled into view.
284
+ if (changed.has('_skillHighlighted') && this._skillMenuOpened) {
285
+ this._skillMenuEl
286
+ ?.querySelector('.skill-menu__item--highlighted')
287
+ ?.scrollIntoView({ block: 'nearest' });
288
+ }
289
+ }
290
+ /** Anchor the fixed skill menu to the composer, opening upward above it. */
291
+ _positionSkillMenu() {
292
+ requestAnimationFrame(() => {
293
+ if (!this._skillMenuEl || !this._inputWrapperEl) {
294
+ return;
295
+ }
296
+ const inputRect = this._inputWrapperEl.getBoundingClientRect();
297
+ this._skillMenuEl.style.left = inputRect.left + 'px';
298
+ this._skillMenuEl.style.width = inputRect.width + 'px';
299
+ // Bound the menu by the chat panel's own top edge so it stays within the
300
+ // widget. In floating mode that keeps it inside the card; in inline mode the
301
+ // panel top sits just below the app header/breadcrumb, so it stays clear of
302
+ // those too. An 8px gap keeps it off both edges.
303
+ const topLimit = this._panelEl.getBoundingClientRect().top + 8;
304
+ const spaceAbove = inputRect.top - 8 - topLimit;
305
+ this._skillMenuEl.style.maxHeight = spaceAbove + 'px';
306
+ // Measure after the cap is applied so the upward anchor uses the real
307
+ // height, then clamp the top edge to the panel bound.
308
+ const menuRect = this._skillMenuEl.getBoundingClientRect();
309
+ const top = Math.max(topLimit, inputRect.top - menuRect.height - 8);
310
+ this._skillMenuEl.style.top = top + 'px';
311
+ });
225
312
  }
226
313
  // --- Public methods ---
227
314
  /**
@@ -280,6 +367,10 @@ let KRChatbot = class KRChatbot extends LitElement {
280
367
  }
281
368
  }
282
369
  _handleHeaderMouseDown(e) {
370
+ // Inline mode fills its parent — dragging the panel doesn't apply.
371
+ if (this.variant === 'inline') {
372
+ return;
373
+ }
283
374
  // Ignore clicks on buttons inside the header
284
375
  if (e.target.closest('button')) {
285
376
  return;
@@ -305,6 +396,10 @@ let KRChatbot = class KRChatbot extends LitElement {
305
396
  document.addEventListener('mouseup', this._handleMouseUp);
306
397
  }
307
398
  _handleResizeMouseDown(e) {
399
+ // Inline mode fills its parent — resizing the panel doesn't apply.
400
+ if (this.variant === 'inline') {
401
+ return;
402
+ }
308
403
  if (!this._panelEl) {
309
404
  return;
310
405
  }
@@ -407,13 +502,129 @@ let KRChatbot = class KRChatbot extends LitElement {
407
502
  const textarea = e.target;
408
503
  textarea.style.height = 'auto';
409
504
  textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
505
+ // Open the skill menu while the message is a `/` command being typed (a
506
+ // leading slash with no space yet). The text after the slash filters it.
507
+ const slashMatch = this._inputValue.match(/^\/(\S*)$/);
508
+ if (slashMatch && this.skills.length) {
509
+ this._skillMenuOpened = true;
510
+ this._skillQuery = slashMatch[1];
511
+ this._skillHighlighted = 0;
512
+ }
513
+ else {
514
+ this._skillMenuOpened = false;
515
+ }
410
516
  }
411
517
  _handleKeyDown(e) {
518
+ // When the skill menu is open, arrow keys / Enter / Escape drive it. The two
519
+ // top actions (Attach Files, Browse All Skills) are index 0 and 1, then the
520
+ // filtered skills follow — all navigable as one list.
521
+ if (this._skillMenuOpened) {
522
+ const count = 2 + this._filteredSkills().length;
523
+ if (e.key === 'ArrowDown') {
524
+ e.preventDefault();
525
+ this._skillHighlighted = (this._skillHighlighted + 1) % count;
526
+ return;
527
+ }
528
+ if (e.key === 'ArrowUp') {
529
+ e.preventDefault();
530
+ this._skillHighlighted = (this._skillHighlighted - 1 + count) % count;
531
+ return;
532
+ }
533
+ if (e.key === 'Enter' && !e.shiftKey) {
534
+ e.preventDefault();
535
+ this._activateSkillMenuItem(this._skillHighlighted);
536
+ return;
537
+ }
538
+ if (e.key === 'Escape') {
539
+ e.preventDefault();
540
+ this._skillMenuOpened = false;
541
+ return;
542
+ }
543
+ }
412
544
  if (e.key === 'Enter' && !e.shiftKey) {
413
545
  e.preventDefault();
414
546
  this._handleSubmit();
415
547
  }
416
548
  }
549
+ /** Run the skill-menu item at the given flat index (0/1 = actions, then skills). */
550
+ _activateSkillMenuItem(index) {
551
+ if (index === 0) {
552
+ this._openFilePicker();
553
+ return;
554
+ }
555
+ if (index === 1) {
556
+ this._handleSkillBrowseOpen();
557
+ return;
558
+ }
559
+ const skill = this._filteredSkills()[index - 2];
560
+ if (skill) {
561
+ this._handleSkillSelect(skill);
562
+ }
563
+ }
564
+ // --- Skills ---
565
+ /** Skills matching the current `/` query, capped at the first 15. */
566
+ _filteredSkills() {
567
+ const query = this._skillQuery.toLowerCase();
568
+ return this.skills
569
+ .filter((skill) => skill.name.toLowerCase().includes(query) ||
570
+ skill.label.toLowerCase().includes(query))
571
+ .slice(0, 15);
572
+ }
573
+ /** The `+` button toggles the skill/attachment menu open (showing all skills). */
574
+ _handlePlusClick() {
575
+ if (this._skillMenuOpened) {
576
+ this._skillMenuOpened = false;
577
+ return;
578
+ }
579
+ this._skillQuery = '';
580
+ this._skillHighlighted = 0;
581
+ this._skillMenuOpened = true;
582
+ }
583
+ /** Insert `/name ` into the composer and focus it, ready for the message. */
584
+ _handleSkillSelect(skill) {
585
+ this._inputValue = `/${skill.name} `;
586
+ this._skillMenuOpened = false;
587
+ this._skillBrowseOpened = false;
588
+ this._inputEl?.focus();
589
+ }
590
+ /** Copy a message's text to the clipboard and briefly flash "Copied". */
591
+ _handleCopyMessage(content, index) {
592
+ navigator.clipboard.writeText(content).then(() => {
593
+ this._copiedIndex = index;
594
+ setTimeout(() => {
595
+ if (this._copiedIndex === index) {
596
+ this._copiedIndex = -1;
597
+ }
598
+ }, 2000);
599
+ }).catch(() => { });
600
+ }
601
+ _handleSkillBrowseOpen() {
602
+ this._skillMenuOpened = false;
603
+ this._skillBrowseQuery = '';
604
+ this._skillBrowseOpened = true;
605
+ }
606
+ _handleSkillBrowseClose() {
607
+ this._skillBrowseOpened = false;
608
+ }
609
+ _handleSkillBrowseSearch(e) {
610
+ this._skillBrowseQuery = e.target.value;
611
+ }
612
+ _handleSkillBrowseTabChange(e) {
613
+ this._skillBrowseSource = e.detail.activeTabId;
614
+ }
615
+ /** Skills for the browse dialog's active tab, filtered by its search box. */
616
+ _browseSkills() {
617
+ const query = this._skillBrowseQuery.toLowerCase();
618
+ return this.skills.filter((skill) => {
619
+ const source = skill.source || 'org';
620
+ if (source !== this._skillBrowseSource) {
621
+ return false;
622
+ }
623
+ return skill.name.toLowerCase().includes(query) ||
624
+ skill.label.toLowerCase().includes(query) ||
625
+ (skill.description || '').toLowerCase().includes(query);
626
+ });
627
+ }
417
628
  // --- Attachments ---
418
629
  _openFilePicker() {
419
630
  this._fileInputEl?.click();
@@ -497,54 +708,49 @@ let KRChatbot = class KRChatbot extends LitElement {
497
708
  name: file.name,
498
709
  previewUrl: URL.createObjectURL(file),
499
710
  };
500
- this._pendingAttachments = [...this._pendingAttachments, pending];
711
+ this._pendingInternalFiles = [...this._pendingInternalFiles, pending];
501
712
  });
502
713
  }
503
714
  _removePending(localId) {
504
- const target = this._pendingAttachments.find((p) => p.localId === localId);
715
+ const target = this._pendingInternalFiles.find((p) => p.localId === localId);
505
716
  if (target) {
506
717
  URL.revokeObjectURL(target.previewUrl);
507
718
  }
508
- this._pendingAttachments = this._pendingAttachments.filter((p) => p.localId !== localId);
719
+ this._pendingInternalFiles = this._pendingInternalFiles.filter((p) => p.localId !== localId);
509
720
  }
510
721
  _clearPending() {
511
- this._pendingAttachments.forEach((p) => URL.revokeObjectURL(p.previewUrl));
512
- this._pendingAttachments = [];
722
+ this._pendingInternalFiles.forEach((p) => URL.revokeObjectURL(p.previewUrl));
723
+ this._pendingInternalFiles = [];
513
724
  }
514
- /** Open the shared full-screen previewer for an attachment. */
515
- _openAttachment(att) {
725
+ /** Open the shared full-screen previewer for a message image. */
726
+ _openImage(img) {
516
727
  KRFilePreview.open({
517
- src: att.url,
518
- name: att.name || 'image',
728
+ src: `/api/system/integration/aws/bedrock/conversationImage/${img.internalFileID}/view`,
729
+ name: img.niceName || 'image',
519
730
  });
520
731
  }
521
732
  /** Whether the send button is active: needs text or at least one image. */
522
733
  _canSend() {
523
- return Boolean(this._inputValue.trim()) || this._pendingAttachments.length > 0;
734
+ return Boolean(this._inputValue.trim()) || this._pendingInternalFiles.length > 0;
524
735
  }
525
736
  _handleSubmit() {
526
- if (!this.send || this._isStreaming) {
737
+ if (this._isStreaming) {
527
738
  return;
528
739
  }
529
740
  const messageText = this._inputValue.trim();
530
- const pending = this._pendingAttachments;
741
+ const pending = this._pendingInternalFiles;
531
742
  // Allow sending an image with no text, but require at least one of them.
532
743
  if (!messageText && !pending.length) {
533
744
  return;
534
745
  }
535
- // The raw files sent to the backend, and the display attachments for the
536
- // sent message. The message reuses each preview object URL, so ownership of
537
- // those URLs transfers to the message (we don't revoke them here).
538
- const files = pending.map((p) => p.file);
539
- const attachments = pending.map((p) => ({
540
- url: p.previewUrl,
541
- name: p.name,
542
- mimeType: p.file.type,
543
- }));
746
+ // The raw files sent to the backend. Sent images are shown once the
747
+ // conversation reloads and they come back as stored internal files; the
748
+ // just-sent message itself carries text only.
749
+ const internalFiles = pending.map((p) => p.file);
544
750
  const submitEvent = new CustomEvent('message-submit', {
545
751
  detail: {
546
752
  message: messageText,
547
- files,
753
+ internalFiles,
548
754
  },
549
755
  bubbles: true,
550
756
  composed: true,
@@ -555,30 +761,26 @@ let KRChatbot = class KRChatbot extends LitElement {
555
761
  return;
556
762
  }
557
763
  const userMessage = {
558
- role: 'user',
764
+ type: 'user',
559
765
  content: messageText,
560
766
  };
561
- if (attachments.length) {
562
- userMessage.attachments = attachments;
563
- }
564
767
  this.messages = [...this.messages, userMessage];
565
768
  this._inputValue = '';
566
769
  this._error = '';
567
770
  this._userHasScrolledUp = false;
568
- // Clear the composer without revoking the object URLs — the sent message now
569
- // owns them for display.
570
- this._pendingAttachments = [];
771
+ // Clear the composer and release its preview object URLs.
772
+ this._pendingInternalFiles.forEach((p) => URL.revokeObjectURL(p.previewUrl));
773
+ this._pendingInternalFiles = [];
571
774
  // Reset textarea height
572
775
  if (this._inputEl) {
573
776
  this._inputEl.style.height = 'auto';
574
777
  }
575
778
  // Add empty assistant message to stream into
576
779
  this.messages = [...this.messages, {
577
- role: 'assistant',
780
+ type: 'assistant',
578
781
  content: '',
579
782
  }];
580
783
  this._isStreaming = true;
581
- this._statusText = '';
582
784
  // Scroll user's message to the top (Gemini-style).
583
785
  // Set min-height on the assistant message = container height so there's enough
584
786
  // scroll room below the user message to push it to the top.
@@ -600,10 +802,34 @@ let KRChatbot = class KRChatbot extends LitElement {
600
802
  this._messagesEl.scrollTop = userMsgEl.offsetTop - this._messagesEl.offsetTop - 24;
601
803
  }
602
804
  });
805
+ // Default to streaming from the Kodaris Bedrock conversation endpoints when
806
+ // the consumer hasn't provided their own `send`.
807
+ if (!this.send) {
808
+ this.send = (params) => {
809
+ // With images, post text + files as multipart; without, post plain JSON.
810
+ if (params.internalFiles.length) {
811
+ const form = new FormData();
812
+ form.append('userText', params.message);
813
+ params.internalFiles.forEach((file) => form.append('images', file));
814
+ return KRHttp.fetch({
815
+ url: '/api/system/integration/aws/bedrock/stream/invokeModelForConversationWithImages',
816
+ method: 'POST',
817
+ headers: { Accept: 'text/event-stream' },
818
+ body: form,
819
+ });
820
+ }
821
+ return KRHttp.fetch({
822
+ url: '/api/system/integration/aws/bedrock/stream/invokeModelForConversationWithState',
823
+ method: 'POST',
824
+ headers: { Accept: 'text/event-stream' },
825
+ body: { userText: params.message },
826
+ });
827
+ };
828
+ }
603
829
  this.send({
604
830
  message: messageText,
605
831
  messages: this.messages.slice(0, -1),
606
- files,
832
+ internalFiles,
607
833
  }).then((response) => {
608
834
  if (!response.ok) {
609
835
  this._handleStreamError('Request failed: ' + response.status);
@@ -629,11 +855,15 @@ let KRChatbot = class KRChatbot extends LitElement {
629
855
  this._reader = null;
630
856
  }
631
857
  this._isStreaming = false;
632
- this._statusText = '';
633
858
  }
634
859
  _handleClear() {
860
+ // Default to clearing the Kodaris Bedrock conversation state when the consumer
861
+ // hasn't provided their own `clear`.
635
862
  if (!this.clear) {
636
- return;
863
+ this.clear = () => KRHttp.fetch({
864
+ url: '/api/system/integration/aws/bedrock/clearConversationWithState',
865
+ method: 'POST',
866
+ });
637
867
  }
638
868
  this.clear().then(() => {
639
869
  if (this._reader) {
@@ -642,7 +872,6 @@ let KRChatbot = class KRChatbot extends LitElement {
642
872
  }
643
873
  this.messages = [];
644
874
  this._isStreaming = false;
645
- this._statusText = '';
646
875
  this._error = '';
647
876
  this._inputValue = '';
648
877
  this._userHasScrolledUp = false;
@@ -668,7 +897,6 @@ let KRChatbot = class KRChatbot extends LitElement {
668
897
  this._reader.read().then((result) => {
669
898
  if (result.done) {
670
899
  this._isStreaming = false;
671
- this._statusText = '';
672
900
  this._reader = null;
673
901
  return;
674
902
  }
@@ -701,15 +929,10 @@ let KRChatbot = class KRChatbot extends LitElement {
701
929
  bubbles: true,
702
930
  composed: true,
703
931
  }));
704
- if (data.type === 'STATUS') {
705
- if (data.message) {
706
- this._statusText = String(data.message);
707
- }
708
- }
709
- else if (data.type === 'REASONING_PART') {
932
+ if (data.type === 'REASONING_PART') {
710
933
  if (data.message) {
711
934
  const lastIndex = this.messages.length - 1;
712
- if (lastIndex >= 0 && this.messages[lastIndex].role === 'assistant') {
935
+ if (lastIndex >= 0 && this.messages[lastIndex].type === 'assistant') {
713
936
  if (!this.messages[lastIndex].reasoning) {
714
937
  this.messages[lastIndex].reasoning = '';
715
938
  }
@@ -720,9 +943,8 @@ let KRChatbot = class KRChatbot extends LitElement {
720
943
  }
721
944
  else if (data.type === 'RESPONSE_PART') {
722
945
  if (data.message) {
723
- this._statusText = '';
724
946
  const lastIndex = this.messages.length - 1;
725
- if (lastIndex >= 0 && this.messages[lastIndex].role === 'assistant') {
947
+ if (lastIndex >= 0 && this.messages[lastIndex].type === 'assistant') {
726
948
  this.messages[lastIndex].content += String(data.message);
727
949
  this.requestUpdate();
728
950
  }
@@ -733,7 +955,6 @@ let KRChatbot = class KRChatbot extends LitElement {
733
955
  }
734
956
  if (data.finished) {
735
957
  this._isStreaming = false;
736
- this._statusText = '';
737
958
  this._reader = null;
738
959
  }
739
960
  }
@@ -747,7 +968,6 @@ let KRChatbot = class KRChatbot extends LitElement {
747
968
  }
748
969
  _handleStreamError(message) {
749
970
  this._isStreaming = false;
750
- this._statusText = '';
751
971
  this._error = message;
752
972
  if (this._reader) {
753
973
  this._reader.cancel();
@@ -756,15 +976,128 @@ let KRChatbot = class KRChatbot extends LitElement {
756
976
  // Remove the empty assistant message if it has no content
757
977
  if (this.messages.length > 0) {
758
978
  const lastMessage = this.messages[this.messages.length - 1];
759
- if (lastMessage.role === 'assistant' && !lastMessage.content) {
979
+ if (lastMessage.type === 'assistant' && !lastMessage.content) {
760
980
  this.messages = this.messages.slice(0, -1);
761
981
  }
762
982
  }
763
983
  }
764
984
  // --- Render ---
985
+ /** The `/` skill menu shown above the composer while typing a slash command. */
986
+ _renderSkillMenu() {
987
+ const skills = this._filteredSkills();
988
+ return html `
989
+ <div class="skill-menu">
990
+ <div class="skill-menu__actions">
991
+ <button
992
+ class=${classMap({
993
+ 'skill-menu__action': true,
994
+ 'skill-menu__item--highlighted': this._skillHighlighted === 0,
995
+ })}
996
+ @click=${this._openFilePicker}
997
+ >
998
+ Attach Files
999
+ </button>
1000
+ <button
1001
+ class=${classMap({
1002
+ 'skill-menu__action': true,
1003
+ 'skill-menu__item--highlighted': this._skillHighlighted === 1,
1004
+ })}
1005
+ @click=${this._handleSkillBrowseOpen}
1006
+ >
1007
+ Browse All Skills
1008
+ </button>
1009
+ </div>
1010
+ ${skills.length ? html `
1011
+ <ul class="skill-menu__list">
1012
+ ${skills.map((skill, index) => html `
1013
+ <li>
1014
+ <button
1015
+ class=${classMap({
1016
+ 'skill-menu__item': true,
1017
+ 'skill-menu__item--highlighted': index + 2 === this._skillHighlighted,
1018
+ })}
1019
+ @click=${() => this._handleSkillSelect(skill)}
1020
+ >
1021
+ <span class="skill-menu__name">${skill.label}</span>
1022
+ ${skill.description ? html `
1023
+ <span class="skill-menu__desc">${skill.description}</span>
1024
+ ` : nothing}
1025
+ </button>
1026
+ </li>
1027
+ `)}
1028
+ </ul>
1029
+ ` : html `
1030
+ <div class="skill-menu__empty">No matching skills</div>
1031
+ `}
1032
+ </div>
1033
+ `;
1034
+ }
1035
+ /** The "Browse all skills" dialog — a searchable grid of Org / Kodaris cards. */
1036
+ _renderSkillBrowse() {
1037
+ return html `
1038
+ <kr-dialog
1039
+ opened
1040
+ label="Browse Skills"
1041
+ width="720px"
1042
+ @close=${this._handleSkillBrowseClose}
1043
+ >
1044
+ <div class="skill-browse">
1045
+ <kr-text-field
1046
+ placeholder="Search skills..."
1047
+ .value=${this._skillBrowseQuery}
1048
+ @input=${this._handleSkillBrowseSearch}
1049
+ ></kr-text-field>
1050
+ <kr-tab-group
1051
+ active-tab-id=${this._skillBrowseSource}
1052
+ @tab-change=${this._handleSkillBrowseTabChange}
1053
+ >
1054
+ <kr-tab id="org" label="Your Org">
1055
+ ${this._renderSkillGrid()}
1056
+ </kr-tab>
1057
+ <kr-tab id="kodaris" label="Kodaris">
1058
+ ${this._renderSkillGrid()}
1059
+ </kr-tab>
1060
+ </kr-tab-group>
1061
+ </div>
1062
+ </kr-dialog>
1063
+ `;
1064
+ }
1065
+ /** The card grid for the browse dialog's active tab. */
1066
+ _renderSkillGrid() {
1067
+ const skills = this._browseSkills();
1068
+ if (!skills.length) {
1069
+ return html `<div class="skill-browse__empty">No matching skills</div>`;
1070
+ }
1071
+ return html `
1072
+ <div class="skill-browse__grid">
1073
+ ${skills.map((skill) => html `
1074
+ <button
1075
+ class="skill-card"
1076
+ @click=${() => this._handleSkillSelect(skill)}
1077
+ >
1078
+ <span class="skill-card__name">${skill.label}</span>
1079
+ ${skill.description ? html `
1080
+ <span class="skill-card__desc">${skill.description}</span>
1081
+ ` : nothing}
1082
+ </button>
1083
+ `)}
1084
+ </div>
1085
+ `;
1086
+ }
1087
+ /**
1088
+ * Render a user message, highlighting a leading `/skill-name` token when it
1089
+ * matches a known skill (like the way Claude highlights slash commands).
1090
+ */
1091
+ _renderUserContent(content) {
1092
+ const match = content.match(/^\/(\S+)(\s[\s\S]*)?$/);
1093
+ if (match && this.skills.some((skill) => skill.name === match[1])) {
1094
+ return html `<span class="message-skill">/${match[1]}</span>${match[2] || ''}`;
1095
+ }
1096
+ return content;
1097
+ }
765
1098
  render() {
766
1099
  return html `
767
- ${this.hideToggle ? nothing : html `
1100
+ ${(this.hideToggle || this.variant === 'inline') ? nothing : html `
768
1101
  <button
769
1102
  class=${classMap({
770
1103
  'toggle': true,
@@ -815,53 +1148,52 @@ let KRChatbot = class KRChatbot extends LitElement {
815
1148
  <span>Drop images to attach</span>
816
1149
  </div>
817
1150
  ` : nothing}
818
- <div
819
- class="resize resize--n"
820
- data-dir="n"
821
- @mousedown=${this._handleResizeMouseDown}
822
- ></div>
823
- <div
824
- class="resize resize--s"
825
- data-dir="s"
826
- @mousedown=${this._handleResizeMouseDown}
827
- ></div>
828
- <div
829
- class="resize resize--w"
830
- data-dir="w"
831
- @mousedown=${this._handleResizeMouseDown}
832
- ></div>
833
- <div
834
- class="resize resize--e"
835
- data-dir="e"
836
- @mousedown=${this._handleResizeMouseDown}
837
- ></div>
838
- <div
839
- class="resize resize--nw"
840
- data-dir="nw"
841
- @mousedown=${this._handleResizeMouseDown}
842
- ></div>
843
- <div
844
- class="resize resize--ne"
845
- data-dir="ne"
846
- @mousedown=${this._handleResizeMouseDown}
847
- ></div>
848
- <div
849
- class="resize resize--sw"
850
- data-dir="sw"
851
- @mousedown=${this._handleResizeMouseDown}
852
- ></div>
853
- <div
854
- class="resize resize--se"
855
- data-dir="se"
856
- @mousedown=${this._handleResizeMouseDown}
857
- ></div>
1151
+ ${this.variant === 'inline' ? nothing : html `
1152
+ <div
1153
+ class="resize resize--n"
1154
+ data-dir="n"
1155
+ @mousedown=${this._handleResizeMouseDown}
1156
+ ></div>
1157
+ <div
1158
+ class="resize resize--s"
1159
+ data-dir="s"
1160
+ @mousedown=${this._handleResizeMouseDown}
1161
+ ></div>
1162
+ <div
1163
+ class="resize resize--w"
1164
+ data-dir="w"
1165
+ @mousedown=${this._handleResizeMouseDown}
1166
+ ></div>
1167
+ <div
1168
+ class="resize resize--e"
1169
+ data-dir="e"
1170
+ @mousedown=${this._handleResizeMouseDown}
1171
+ ></div>
1172
+ <div
1173
+ class="resize resize--nw"
1174
+ data-dir="nw"
1175
+ @mousedown=${this._handleResizeMouseDown}
1176
+ ></div>
1177
+ <div
1178
+ class="resize resize--ne"
1179
+ data-dir="ne"
1180
+ @mousedown=${this._handleResizeMouseDown}
1181
+ ></div>
1182
+ <div
1183
+ class="resize resize--sw"
1184
+ data-dir="sw"
1185
+ @mousedown=${this._handleResizeMouseDown}
1186
+ ></div>
1187
+ <div
1188
+ class="resize resize--se"
1189
+ data-dir="se"
1190
+ @mousedown=${this._handleResizeMouseDown}
1191
+ ></div>
1192
+ `}
858
1193
  <div class="header" @mousedown=${this._handleHeaderMouseDown}>
859
1194
  <div class="header-left">
860
1195
  <div class="header-info">
861
- <span class="header-title">${this.title}</span>
862
- ${this.subtitle ? html `
863
- <span class="header-subtitle">${this.subtitle}</span>
864
- ` : nothing}
1196
+ <span class="header-title">${this.label}</span>
865
1197
  </div>
866
1198
  </div>
867
1199
  <div class="header-actions">
@@ -887,77 +1219,124 @@ let KRChatbot = class KRChatbot extends LitElement {
887
1219
  </svg>
888
1220
  </button>
889
1221
  ` : nothing}
890
- <button
891
- class="header-action"
892
- @click=${this._handleToggle}
893
- title="Close"
894
- >
895
- <svg
896
- xmlns="http://www.w3.org/2000/svg"
897
- fill="none"
898
- viewBox="0 0 24 24"
899
- stroke-width="2"
900
- stroke="currentColor"
901
- class="header-action-icon"
1222
+ ${this.variant === 'inline' ? nothing : html `
1223
+ <button
1224
+ class="header-action"
1225
+ @click=${this._handleToggle}
1226
+ title="Close"
902
1227
  >
903
- <path
904
- stroke-linecap="round"
905
- stroke-linejoin="round"
906
- d="M6 18 18 6M6 6l12 12"
907
- />
908
- </svg>
909
- </button>
1228
+ <svg
1229
+ xmlns="http://www.w3.org/2000/svg"
1230
+ fill="none"
1231
+ viewBox="0 0 24 24"
1232
+ stroke-width="2"
1233
+ stroke="currentColor"
1234
+ class="header-action-icon"
1235
+ >
1236
+ <path
1237
+ stroke-linecap="round"
1238
+ stroke-linejoin="round"
1239
+ d="M6 18 18 6M6 6l12 12"
1240
+ />
1241
+ </svg>
1242
+ </button>
1243
+ `}
910
1244
  </div>
911
1245
  </div>
912
1246
 
913
1247
  <div class="messages" @scroll=${this._handleMessagesScroll}>
914
- ${this.messages.length === 0 ? html `
915
- <div class="empty">
916
- <span class="empty-text">What can I help you with?</span>
917
- </div>
918
- ` : nothing}
919
- ${this.messages.map((msg) => html `
920
- <div class=${classMap({
1248
+ <div class="messages-inner">
1249
+ ${this.messages.length === 0 ? html `
1250
+ <div class="empty">
1251
+ <span class="empty-text">What can I help you with?</span>
1252
+ </div>
1253
+ ` : nothing}
1254
+ ${this.messages.map((msg, index) => html `
1255
+ <div class=${classMap({
921
1256
  'message': true,
922
- 'message--user': msg.role === 'user',
923
- 'message--assistant': msg.role === 'assistant',
1257
+ 'message--user': msg.type === 'user',
1258
+ 'message--assistant': msg.type === 'assistant',
924
1259
  })}>
925
- ${msg.reasoning ? html `
926
- <details class="reasoning">
927
- <summary>Thinking</summary>
928
- <div class="reasoning-content">${msg.reasoning}</div>
929
- </details>
930
- ` : nothing}
931
- ${msg.attachments && msg.attachments.length ? html `
932
- <div class="attachments">
933
- ${msg.attachments.map((att) => html `
934
- <button
935
- class="attachment"
936
- type="button"
937
- title=${att.name || 'View image'}
938
- @click=${() => this._openAttachment(att)}
939
- >
940
- <img src=${att.url} alt=${att.name || ''} />
941
- </button>
942
- `)}
943
- </div>
944
- ` : nothing}
945
- ${msg.content ? html `
946
- <div class="message-content">
947
- ${msg.role === 'user'
948
- ? msg.content
1260
+ ${msg.reasoning ? html `
1261
+ <details class="reasoning">
1262
+ <summary>Thinking</summary>
1263
+ <div class="reasoning-content">${msg.reasoning}</div>
1264
+ </details>
1265
+ ` : nothing}
1266
+ ${msg.internalFiles && msg.internalFiles.length ? html `
1267
+ <div class="attachments">
1268
+ ${msg.internalFiles.map((img) => html `
1269
+ <button
1270
+ class="attachment"
1271
+ type="button"
1272
+ title=${img.niceName || 'View image'}
1273
+ @click=${() => this._openImage(img)}
1274
+ >
1275
+ <img
1276
+ src="/api/system/integration/aws/bedrock/conversationImage/${img.internalFileID}/view"
1277
+ alt=${img.niceName || ''}
1278
+ />
1279
+ </button>
1280
+ `)}
1281
+ </div>
1282
+ ` : nothing}
1283
+ ${msg.content ? html `
1284
+ <div class="message-content">
1285
+ ${msg.type === 'user'
1286
+ ? this._renderUserContent(msg.content)
949
1287
  : unsafeHTML(marked.parse(msg.content, { breaks: true }))}
950
- </div>
951
- ` : nothing}
952
- ${msg.role === 'assistant' && this._isStreaming && msg === this.messages[this.messages.length - 1] ? html `
953
- <div class="typing">
954
- <span></span>
955
- <span></span>
956
- <span></span>
957
- </div>
958
- ` : nothing}
959
- </div>
960
- `)}
1288
+ </div>
1289
+ ` : nothing}
1290
+ ${msg.type === 'assistant' && this._isStreaming && msg === this.messages[this.messages.length - 1] ? html `
1291
+ <div class="typing">
1292
+ <span></span>
1293
+ <span></span>
1294
+ <span></span>
1295
+ </div>
1296
+ ` : nothing}
1297
+ ${msg.content ? html `
1298
+ <button
1299
+ class="message-copy"
1300
+ title="Copy"
1301
+ @click=${() => this._handleCopyMessage(msg.content, index)}
1302
+ >
1303
+ ${this._copiedIndex === index ? html `
1304
+ <svg
1305
+ viewBox="0 0 24 24"
1306
+ fill="none"
1307
+ stroke="currentColor"
1308
+ stroke-width="1.5"
1309
+ stroke-linecap="round"
1310
+ stroke-linejoin="round"
1311
+ >
1312
+ <path d="M20 6 9 17l-5-5" />
1313
+ </svg>
1314
+ Copied
1315
+ ` : html `
1316
+ <svg
1317
+ viewBox="0 0 24 24"
1318
+ fill="none"
1319
+ stroke="currentColor"
1320
+ stroke-width="1.5"
1321
+ stroke-linecap="round"
1322
+ stroke-linejoin="round"
1323
+ >
1324
+ <rect
1325
+ x="9"
1326
+ y="9"
1327
+ width="13"
1328
+ height="13"
1329
+ rx="2"
1330
+ />
1331
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
1332
+ </svg>
1333
+ Copy
1334
+ `}
1335
+ </button>
1336
+ ` : nothing}
1337
+ </div>
1338
+ `)}
1339
+ </div>
961
1340
  </div>
962
1341
 
963
1342
  ${this._error ? html `
@@ -983,9 +1362,9 @@ let KRChatbot = class KRChatbot extends LitElement {
983
1362
  ` : nothing}
984
1363
 
985
1364
  <div class="footer">
986
- ${this._pendingAttachments.length ? html `
1365
+ ${this._pendingInternalFiles.length ? html `
987
1366
  <div class="composer-attachments">
988
- ${repeat(this._pendingAttachments, (p) => p.localId, (p) => html `
1367
+ ${repeat(this._pendingInternalFiles, (p) => p.localId, (p) => html `
989
1368
  <div class="chip">
990
1369
  <img src=${p.previewUrl} alt=${p.name} />
991
1370
  <button
@@ -1019,11 +1398,11 @@ let KRChatbot = class KRChatbot extends LitElement {
1019
1398
  hidden
1020
1399
  @change=${this._handleFileInputChange}
1021
1400
  />
1401
+ ${this._skillMenuOpened ? this._renderSkillMenu() : nothing}
1022
1402
  <div class="input-wrapper">
1023
1403
  <button
1024
1404
  class="input-plus"
1025
- title="Attach image"
1026
- @click=${this._openFilePicker}
1405
+ @click=${this._handlePlusClick}
1027
1406
  >+</button>
1028
1407
  <textarea
1029
1408
  class="input"
@@ -1080,6 +1459,7 @@ let KRChatbot = class KRChatbot extends LitElement {
1080
1459
  `}
1081
1460
  </div>
1082
1461
  </div>
1462
+ ${this._skillBrowseOpened ? this._renderSkillBrowse() : nothing}
1083
1463
  </div>
1084
1464
  `;
1085
1465
  }
@@ -1174,6 +1554,28 @@ KRChatbot.styles = css `
1174
1554
  display: flex;
1175
1555
  }
1176
1556
 
1557
+ /* Inline mode: fill the parent container instead of floating bottom-right. */
1558
+ :host([variant='inline']) {
1559
+ position: static;
1560
+ bottom: auto;
1561
+ right: auto;
1562
+ z-index: auto;
1563
+ height: 100%;
1564
+ }
1565
+
1566
+ :host([variant='inline']) .panel {
1567
+ position: relative;
1568
+ bottom: auto;
1569
+ right: auto;
1570
+ width: 100%;
1571
+ height: 100%;
1572
+ max-height: none;
1573
+ border-radius: 0;
1574
+ box-shadow: none;
1575
+ border: none;
1576
+ animation: none;
1577
+ }
1578
+
1177
1579
  @keyframes chatbot-panel-in {
1178
1580
  from {
1179
1581
  opacity: 0;
@@ -1204,7 +1606,7 @@ KRChatbot.styles = css `
1204
1606
  gap: 10px;
1205
1607
  }
1206
1608
 
1207
- .header-info {
1609
+ .header-info {
1208
1610
  display: flex;
1209
1611
  flex-direction: column;
1210
1612
  }
@@ -1214,22 +1616,6 @@ KRChatbot.styles = css `
1214
1616
  font-weight: 600;
1215
1617
  }
1216
1618
 
1217
- .header-subtitle {
1218
- color: rgba(255, 255, 255, 0.5);
1219
- font-size: 11px;
1220
- display: flex;
1221
- align-items: center;
1222
- gap: 4px;
1223
- }
1224
-
1225
- .header-subtitle::before {
1226
- content: '';
1227
- width: 5px;
1228
- height: 5px;
1229
- background: var(--kr-chatbot-success);
1230
- border-radius: 50%;
1231
- }
1232
-
1233
1619
  .header-actions {
1234
1620
  display: flex;
1235
1621
  align-items: center;
@@ -1261,17 +1647,29 @@ KRChatbot.styles = css `
1261
1647
  }
1262
1648
 
1263
1649
  /* Messages */
1650
+ /* Full-width scroll container — keeps the scrollbar at the panel edge. */
1264
1651
  .messages {
1265
1652
  flex: 1;
1266
1653
  overflow-y: auto;
1267
- padding: 32px;
1268
1654
  display: flex;
1269
1655
  flex-direction: column;
1270
- gap: 24px;
1271
1656
  scroll-behavior: smooth;
1272
1657
  background: white;
1273
1658
  }
1274
1659
 
1660
+ /* Centered, width-capped column that actually holds the messages. */
1661
+ .messages-inner {
1662
+ flex: 1;
1663
+ display: flex;
1664
+ flex-direction: column;
1665
+ gap: 24px;
1666
+ padding: 32px;
1667
+ width: 100%;
1668
+ max-width: 768px;
1669
+ margin-left: auto;
1670
+ margin-right: auto;
1671
+ }
1672
+
1275
1673
  .messages::-webkit-scrollbar {
1276
1674
  width: 4px;
1277
1675
  }
@@ -1295,12 +1693,13 @@ KRChatbot.styles = css `
1295
1693
  gap: 8px;
1296
1694
  }
1297
1695
 
1298
- .empty-text {
1696
+ .empty-text {
1299
1697
  font-size: 14px;
1300
1698
  color: rgb(0 0 0 / 80%);
1301
1699
  }
1302
1700
 
1303
1701
  .message {
1702
+ position: relative;
1304
1703
  display: flex;
1305
1704
  flex-direction: column;
1306
1705
  max-width: 100%;
@@ -1345,6 +1744,58 @@ KRChatbot.styles = css `
1345
1744
  font-size: 14px;
1346
1745
  }
1347
1746
 
1747
+ /* Highlighted leading /skill token in a sent user message. Monospace + a
1748
+ stronger color makes it read as a distinct command without a pill. */
1749
+ .message-skill {
1750
+ font-family: 'SF Mono', SFMono-Regular, ui-monospace, Menlo, Consolas, monospace;
1751
+ font-weight: 600;
1752
+ color: #b45309;
1753
+ }
1754
+
1755
+ /* Per-message copy button. Kept in flow with a fixed height that is always
1756
+ reserved (via a negative bottom margin that cancels it) so message spacing
1757
+ is identical whether or not it's shown; only its opacity toggles on hover. */
1758
+ .message-copy {
1759
+ align-self: flex-start;
1760
+ margin-top: 4px;
1761
+ margin-bottom: -27px;
1762
+ display: inline-flex;
1763
+ align-items: center;
1764
+ gap: 4px;
1765
+ height: 23px;
1766
+ padding: 4px 8px;
1767
+ background: none;
1768
+ border: none;
1769
+ border-radius: 6px;
1770
+ font: inherit;
1771
+ font-size: 12px;
1772
+ color: #64668b;
1773
+ cursor: pointer;
1774
+ opacity: 0;
1775
+ pointer-events: none;
1776
+ transition: opacity 0.15s, background-color 0.15s, color 0.15s;
1777
+ }
1778
+
1779
+ .message--user .message-copy {
1780
+ align-self: flex-end;
1781
+ }
1782
+
1783
+ .message:hover .message-copy,
1784
+ .message-copy:focus-visible {
1785
+ opacity: 1;
1786
+ pointer-events: auto;
1787
+ }
1788
+
1789
+ .message-copy:hover {
1790
+ background: rgba(0, 0, 0, 0.06);
1791
+ color: #1a1a2e;
1792
+ }
1793
+
1794
+ .message-copy svg {
1795
+ width: 14px;
1796
+ height: 14px;
1797
+ }
1798
+
1348
1799
  .message--assistant .message-content {
1349
1800
  background: transparent;
1350
1801
  color: #000000;
@@ -1407,7 +1858,7 @@ KRChatbot.styles = css `
1407
1858
  word-wrap: break-word;
1408
1859
  }
1409
1860
 
1410
- .typing {
1861
+ .typing {
1411
1862
  display: flex;
1412
1863
  gap: 4px;
1413
1864
  padding: 4px 0;
@@ -1475,6 +1926,15 @@ KRChatbot.styles = css `
1475
1926
  padding: 16px 16px 16px;
1476
1927
  flex-shrink: 0;
1477
1928
  background: white;
1929
+ width: 100%;
1930
+ max-width: 768px;
1931
+ margin-left: auto;
1932
+ margin-right: auto;
1933
+ }
1934
+
1935
+ /* Inline mode: keep the composer clear of the bottom edge. */
1936
+ :host([variant='inline']) .footer {
1937
+ padding-bottom: 40px;
1478
1938
  }
1479
1939
 
1480
1940
  .input-wrapper {
@@ -1493,6 +1953,15 @@ KRChatbot.styles = css `
1493
1953
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04);
1494
1954
  }
1495
1955
 
1956
+ /* Inline mode: keep the border and add a soft floating shadow (ChatGPT/Gemini style). */
1957
+ :host([variant='inline']) .input-wrapper {
1958
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06), 0 8px 24px rgba(0, 0, 0, 0.1);
1959
+ }
1960
+
1961
+ :host([variant='inline']) .input-wrapper:focus-within {
1962
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 12px 32px rgba(0, 0, 0, 0.14);
1963
+ }
1964
+
1496
1965
  .input-plus {
1497
1966
  width: 34px;
1498
1967
  height: 34px;
@@ -1600,6 +2069,12 @@ KRChatbot.styles = css `
1600
2069
  cursor: grabbing;
1601
2070
  }
1602
2071
 
2072
+ /* Inline mode isn't draggable — don't show the grab/hand cursor. */
2073
+ :host([variant='inline']) .header,
2074
+ :host([variant='inline']) .header:active {
2075
+ cursor: default;
2076
+ }
2077
+
1603
2078
  /* Drag / Resize states */
1604
2079
  .panel--dragging,
1605
2080
  .panel--resizing {
@@ -1656,6 +2131,176 @@ KRChatbot.styles = css `
1656
2131
  display: block;
1657
2132
  }
1658
2133
 
2134
+ /* Skill menu (opened by typing '/'). Fixed + JS-positioned above the
2135
+ composer, following the krubble select-field dropdown pattern. */
2136
+ .skill-menu {
2137
+ position: fixed;
2138
+ z-index: 10000;
2139
+ background: white;
2140
+ border: 1px solid #9ba7b6;
2141
+ border-radius: 8px;
2142
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
2143
+ overflow: hidden;
2144
+ display: flex;
2145
+ flex-direction: column;
2146
+ /* max-height is set dynamically to the space above the composer. */
2147
+ }
2148
+
2149
+ /* Floating mode is a compact card — use a lighter shadow and softer border. */
2150
+ :host(:not([variant='inline'])) .skill-menu {
2151
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
2152
+ border-color: #b0b0b0;
2153
+ }
2154
+
2155
+ .skill-menu__list {
2156
+ list-style: none;
2157
+ margin: 0;
2158
+ padding: 8px 0;
2159
+ overflow-y: auto;
2160
+ }
2161
+
2162
+ .skill-menu__item {
2163
+ display: flex;
2164
+ flex-direction: column;
2165
+ gap: 2px;
2166
+ width: 100%;
2167
+ padding: 8px 16px;
2168
+ background: none;
2169
+ border: none;
2170
+ text-align: left;
2171
+ cursor: pointer;
2172
+ font: inherit;
2173
+ color: #000000;
2174
+ }
2175
+
2176
+
2177
+ /* Floating mode is a compact card — tighten name/description spacing. */
2178
+ :host(:not([variant='inline'])) .skill-menu__item {
2179
+ gap: 1px;
2180
+ }
2181
+
2182
+ .skill-menu__name {
2183
+ font-weight: 500;
2184
+ }
2185
+
2186
+ /* Floating mode is a compact card — shrink the skill name a touch. */
2187
+ :host(:not([variant='inline'])) .skill-menu__name {
2188
+ font-size: 13px;
2189
+ }
2190
+
2191
+ .skill-menu__desc {
2192
+ font-size: 12px;
2193
+ color: #000000;
2194
+ overflow: hidden;
2195
+ text-overflow: ellipsis;
2196
+ white-space: nowrap;
2197
+ }
2198
+
2199
+ .skill-menu__empty {
2200
+ padding: 16px;
2201
+ text-align: center;
2202
+ color: #000000;
2203
+ }
2204
+
2205
+ .skill-menu__actions {
2206
+ display: flex;
2207
+ flex-direction: column;
2208
+ padding: 8px 0;
2209
+ border-bottom: 1px solid #eee;
2210
+ flex-shrink: 0;
2211
+ }
2212
+
2213
+ .skill-menu__action {
2214
+ width: 100%;
2215
+ padding: 6px 16px;
2216
+ background: transparent;
2217
+ border: none;
2218
+ text-align: left;
2219
+ cursor: pointer;
2220
+ font: inherit;
2221
+ font-size: 13px;
2222
+ color: var(--kr-chatbot-primary);
2223
+ font-weight: 500;
2224
+ }
2225
+
2226
+ /* Highlight (keyboard) and hover (mouse) are independent — either shades the
2227
+ row. Declared after the base rules so it wins the cascade for both the
2228
+ action buttons and the skill items. */
2229
+ .skill-menu__action.skill-menu__item--highlighted,
2230
+ .skill-menu__item.skill-menu__item--highlighted,
2231
+ .skill-menu__action:hover,
2232
+ .skill-menu__item:hover {
2233
+ background: #f3f4f6;
2234
+ }
2235
+
2236
+ /* Browse-all dialog body. Owns the height so the dialog is the same size on
2237
+ every tab; the search is fixed and the card grid flexes/scrolls to fill. */
2238
+ .skill-browse {
2239
+ display: flex;
2240
+ flex-direction: column;
2241
+ height: 70vh;
2242
+ }
2243
+
2244
+ .skill-browse kr-text-field {
2245
+ display: block;
2246
+ margin: 20px 20px 16px;
2247
+ flex-shrink: 0;
2248
+ }
2249
+
2250
+ .skill-browse kr-tab-group {
2251
+ flex: 1;
2252
+ min-height: 0;
2253
+ }
2254
+
2255
+ /* The grid is the scroller and spans the full width, so its scrollbar sits
2256
+ flush against the dialog edge; padding-right insets the cards from it. */
2257
+ .skill-browse__grid {
2258
+ height: 100%;
2259
+ overflow-y: auto;
2260
+ display: grid;
2261
+ grid-template-columns: repeat(3, 1fr);
2262
+ grid-auto-rows: min-content;
2263
+ align-content: start;
2264
+ gap: 12px;
2265
+ padding: 16px 20px 20px;
2266
+ }
2267
+
2268
+ .skill-card {
2269
+ display: flex;
2270
+ flex-direction: column;
2271
+ gap: 6px;
2272
+ padding: 14px;
2273
+ background: white;
2274
+ border: 1px solid #e5e7eb;
2275
+ border-radius: 10px;
2276
+ text-align: left;
2277
+ cursor: pointer;
2278
+ font: inherit;
2279
+ color: #000000;
2280
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
2281
+ }
2282
+
2283
+ .skill-card:hover {
2284
+ border-color: var(--kr-chatbot-primary);
2285
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
2286
+ }
2287
+
2288
+ .skill-card__name {
2289
+ font-weight: 600;
2290
+ }
2291
+
2292
+ .skill-card__desc {
2293
+ font-size: 12px;
2294
+ color: #000000;
2295
+ }
2296
+
2297
+ .skill-browse__empty {
2298
+ height: 100%;
2299
+ padding: 32px;
2300
+ text-align: center;
2301
+ color: #000000;
2302
+ }
2303
+
1659
2304
  /* Composer attachment chips (pre-send) */
1660
2305
  .composer-attachments {
1661
2306
  display: flex;
@@ -1751,13 +2396,13 @@ __decorate([
1751
2396
  ], KRChatbot.prototype, "messages", void 0);
1752
2397
  __decorate([
1753
2398
  property({ type: String })
1754
- ], KRChatbot.prototype, "title", void 0);
1755
- __decorate([
1756
- property({ type: String })
1757
- ], KRChatbot.prototype, "subtitle", void 0);
2399
+ ], KRChatbot.prototype, "label", void 0);
1758
2400
  __decorate([
1759
2401
  property({ type: String })
1760
2402
  ], KRChatbot.prototype, "placeholder", void 0);
2403
+ __decorate([
2404
+ property({ attribute: false })
2405
+ ], KRChatbot.prototype, "skills", void 0);
1761
2406
  __decorate([
1762
2407
  property({
1763
2408
  type: Boolean,
@@ -1771,24 +2416,48 @@ __decorate([
1771
2416
  reflect: true
1772
2417
  })
1773
2418
  ], KRChatbot.prototype, "hideToggle", void 0);
2419
+ __decorate([
2420
+ property({
2421
+ type: String,
2422
+ reflect: true
2423
+ })
2424
+ ], KRChatbot.prototype, "variant", void 0);
1774
2425
  __decorate([
1775
2426
  state()
1776
2427
  ], KRChatbot.prototype, "_inputValue", void 0);
1777
2428
  __decorate([
1778
2429
  state()
1779
2430
  ], KRChatbot.prototype, "_isStreaming", void 0);
1780
- __decorate([
1781
- state()
1782
- ], KRChatbot.prototype, "_statusText", void 0);
1783
2431
  __decorate([
1784
2432
  state()
1785
2433
  ], KRChatbot.prototype, "_error", void 0);
1786
2434
  __decorate([
1787
2435
  state()
1788
- ], KRChatbot.prototype, "_pendingAttachments", void 0);
2436
+ ], KRChatbot.prototype, "_pendingInternalFiles", void 0);
1789
2437
  __decorate([
1790
2438
  state()
1791
2439
  ], KRChatbot.prototype, "_isDraggingFile", void 0);
2440
+ __decorate([
2441
+ state()
2442
+ ], KRChatbot.prototype, "_skillMenuOpened", void 0);
2443
+ __decorate([
2444
+ state()
2445
+ ], KRChatbot.prototype, "_skillQuery", void 0);
2446
+ __decorate([
2447
+ state()
2448
+ ], KRChatbot.prototype, "_skillHighlighted", void 0);
2449
+ __decorate([
2450
+ state()
2451
+ ], KRChatbot.prototype, "_copiedIndex", void 0);
2452
+ __decorate([
2453
+ state()
2454
+ ], KRChatbot.prototype, "_skillBrowseOpened", void 0);
2455
+ __decorate([
2456
+ state()
2457
+ ], KRChatbot.prototype, "_skillBrowseSource", void 0);
2458
+ __decorate([
2459
+ state()
2460
+ ], KRChatbot.prototype, "_skillBrowseQuery", void 0);
1792
2461
  __decorate([
1793
2462
  query('.panel')
1794
2463
  ], KRChatbot.prototype, "_panelEl", void 0);
@@ -1801,6 +2470,12 @@ __decorate([
1801
2470
  __decorate([
1802
2471
  query('.file-input')
1803
2472
  ], KRChatbot.prototype, "_fileInputEl", void 0);
2473
+ __decorate([
2474
+ query('.input-wrapper')
2475
+ ], KRChatbot.prototype, "_inputWrapperEl", void 0);
2476
+ __decorate([
2477
+ query('.skill-menu')
2478
+ ], KRChatbot.prototype, "_skillMenuEl", void 0);
1804
2479
  KRChatbot = __decorate([
1805
2480
  customElement('kr-chatbot')
1806
2481
  ], KRChatbot);