@kodaris/krubble-app-components 1.0.126 → 1.0.128

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.
@@ -3473,10 +3473,9 @@
3473
3473
  }
3474
3474
  }
3475
3475
  // `this.load` is always set by here (defaulted above when the consumer didn't
3476
- // supply one), so capture it for the async chain — TS can't narrow the nullable
3477
- // property across the promise boundary.
3478
- const load = this.load;
3479
- resolveConversationId.then(() => load()).then((messages) => {
3476
+ // supply one); the `!` is because TS can't narrow the nullable property across
3477
+ // the promise boundary.
3478
+ resolveConversationId.then(() => this.load()).then((messages) => {
3480
3479
  if (messages && messages.length) {
3481
3480
  this.messages = messages;
3482
3481
  }
@@ -3745,11 +3744,10 @@
3745
3744
  }
3746
3745
  _saveState() {
3747
3746
  try {
3748
- // `toolDetail` and `history` are transient views, not saved ones — persist
3749
- // only the real preference views so a reload never reopens a stale tool or a
3750
- // stale conversation list.
3747
+ // `toolDetail` is a transient peek, not a saved view — persist only the two
3748
+ // real preference views so a reload never reopens a stale tool.
3751
3749
  let persistedDrawerView = this._drawerView;
3752
- if (persistedDrawerView === 'toolDetail' || persistedDrawerView === 'history') {
3750
+ if (persistedDrawerView === 'toolDetail') {
3753
3751
  persistedDrawerView = 'settings';
3754
3752
  }
3755
3753
  const state = {
@@ -3808,8 +3806,14 @@
3808
3806
  this._showToolInputs = state.showToolInputs === 'true';
3809
3807
  this._showConfirmationInput = state.showConfirmationInput === 'true';
3810
3808
  this._drawerOpen = state.drawerOpen === 'true';
3811
- if (state.drawerView === 'settings' || state.drawerView === 'debug') {
3809
+ if (state.drawerView === 'settings' || state.drawerView === 'debug' || state.drawerView === 'history') {
3812
3810
  this._drawerView = state.drawerView;
3811
+ // History's list isn't persisted (it can go stale); refetch it so a reload
3812
+ // that was on the conversations panel comes back to a populated list rather
3813
+ // than an empty one.
3814
+ if (state.drawerView === 'history') {
3815
+ this._loadConversations();
3816
+ }
3813
3817
  }
3814
3818
  const w = Number(state.drawerWidth);
3815
3819
  if (w >= 300) {
@@ -4953,41 +4957,47 @@
4953
4957
  else {
4954
4958
  this._drawerOpen = true;
4955
4959
  this._drawerView = view;
4956
- // Opening the history view refetches the user's previous conversations
4957
- // (most-recently modified first) so a just-created or renamed conversation
4958
- // shows without a reload. Ownership is enforced server-side (the endpoint
4959
- // scopes to the current administrator), so we only filter to active ones.
4960
+ // Opening the history view refetches the list so a just-created or renamed
4961
+ // conversation shows without a reload.
4960
4962
  if (view === 'history') {
4961
- this._conversationsLoading = true;
4962
- krubbleHttp.Http.fetch({
4963
- url: '/api/system/conversation/list',
4964
- method: 'POST',
4965
- body: {
4966
- page: 0,
4967
- size: 50,
4968
- sortBy: 'lastModified',
4969
- sortDirection: 'desc',
4970
- filterFields: [{
4971
- name: 'active',
4972
- operation: 'IS',
4973
- value: 'true',
4974
- }],
4975
- },
4976
- })
4977
- .then((r) => r.json())
4978
- .then((res) => {
4979
- this._conversations = res?.data?.content || [];
4980
- })
4981
- .catch(() => {
4982
- this._conversations = [];
4983
- })
4984
- .then(() => {
4985
- this._conversationsLoading = false;
4986
- });
4963
+ this._loadConversations();
4987
4964
  }
4988
4965
  }
4989
4966
  this._saveState();
4990
4967
  }
4968
+ /**
4969
+ * Fetch the user's previous conversations for the history list, most-recently
4970
+ * modified first. Ownership is enforced server-side (the endpoint scopes to the
4971
+ * current administrator), so we only filter to active ones.
4972
+ */
4973
+ _loadConversations() {
4974
+ this._conversationsLoading = true;
4975
+ krubbleHttp.Http.fetch({
4976
+ url: '/api/system/conversation/list',
4977
+ method: 'POST',
4978
+ body: {
4979
+ page: 0,
4980
+ size: 50,
4981
+ sortBy: 'lastModified',
4982
+ sortDirection: 'desc',
4983
+ filterFields: [{
4984
+ name: 'active',
4985
+ operation: 'IS',
4986
+ value: 'true',
4987
+ }],
4988
+ },
4989
+ })
4990
+ .then((r) => r.json())
4991
+ .then((res) => {
4992
+ this._conversations = res?.data?.content || [];
4993
+ })
4994
+ .catch(() => {
4995
+ this._conversations = [];
4996
+ })
4997
+ .then(() => {
4998
+ this._conversationsLoading = false;
4999
+ });
5000
+ }
4991
5001
  /**
4992
5002
  * Switch the floating widget to a previously-created conversation (clicked in the
4993
5003
  * history list): stop any live turn, reset the transient turn/context state, pin
@@ -5006,7 +5016,22 @@
5006
5016
  // one, so the header falls back to the default label).
5007
5017
  this._conversationTitle = this._conversations.find((c) => c.conversationID === conversationID)?.title || '';
5008
5018
  this._persistConversationId();
5009
- this._resetConversationState();
5019
+ // Clear the transient, per-conversation state so it doesn't bleed into the one
5020
+ // we're switching to — pending confirmations, context banners, errors, the
5021
+ // reconnect counter, and staged composer files (the transcript is loaded below).
5022
+ this._pendingActions = [];
5023
+ this._inlineConfirm = true;
5024
+ this._contextWarning = '';
5025
+ this._contextLimitReached = false;
5026
+ this._contextLimitMessage = '';
5027
+ this._error = '';
5028
+ this._reconnectAttempts = 0;
5029
+ this._pendingConversationFiles.forEach((p) => {
5030
+ if (p.previewUrl) {
5031
+ URL.revokeObjectURL(p.previewUrl);
5032
+ }
5033
+ });
5034
+ this._pendingConversationFiles = [];
5010
5035
  this._drawerOpen = false;
5011
5036
  this._saveState();
5012
5037
  if (this.load) {
@@ -5033,17 +5058,9 @@
5033
5058
  this._conversationTitle = '';
5034
5059
  this._persistConversationId();
5035
5060
  this.messages = [];
5036
- this._resetConversationState();
5037
- this._drawerOpen = false;
5038
- this._saveState();
5039
- }
5040
- /**
5041
- * Clear the transient, per-conversation state that must not bleed across a
5042
- * conversation switch/new — pending confirmations, context banners, errors, the
5043
- * reconnect counter, and staged composer files. The transcript itself is set by
5044
- * the caller (loaded for a switch, emptied for new).
5045
- */
5046
- _resetConversationState() {
5061
+ // Clear the transient, per-conversation state so it doesn't bleed into the new
5062
+ // conversation — pending confirmations, context banners, errors, the reconnect
5063
+ // counter, and staged composer files.
5047
5064
  this._pendingActions = [];
5048
5065
  this._inlineConfirm = true;
5049
5066
  this._contextWarning = '';
@@ -5057,6 +5074,8 @@
5057
5074
  }
5058
5075
  });
5059
5076
  this._pendingConversationFiles = [];
5077
+ this._drawerOpen = false;
5078
+ this._saveState();
5060
5079
  }
5061
5080
  /** Close the drawer. `_drawerView` is left intact so its content stays visible
5062
5081
  * while the drawer slides shut. */
@@ -5939,55 +5958,58 @@
5939
5958
  <div class="history__empty">No previous conversations</div>
5940
5959
  ` : b `
5941
5960
  <div class="history__list">
5942
- ${this._conversations.map((conversation) => b `
5943
- <button
5944
- type="button"
5945
- class=${e({
5946
- 'history__item': true,
5947
- 'history__item--active': conversation.conversationID === this.conversationId,
5961
+ ${this._conversations.map((conversation) => {
5962
+ // Format lastModified for the row: today shows the time, this year
5963
+ // shows month/day, older shows month/day/year; the raw value if it
5964
+ // isn't a parseable date. Empty when the conversation has no date.
5965
+ let dateLabel = '';
5966
+ if (conversation.lastModified) {
5967
+ const date = new Date(conversation.lastModified);
5968
+ const now = new Date();
5969
+ if (isNaN(date.getTime())) {
5970
+ dateLabel = conversation.lastModified;
5971
+ }
5972
+ else if (date.toDateString() === now.toDateString()) {
5973
+ dateLabel = date.toLocaleTimeString('en-US', {
5974
+ hour: 'numeric',
5975
+ minute: '2-digit',
5976
+ });
5977
+ }
5978
+ else if (date.getFullYear() === now.getFullYear()) {
5979
+ dateLabel = date.toLocaleDateString('en-US', {
5980
+ month: 'short',
5981
+ day: 'numeric',
5982
+ });
5983
+ }
5984
+ else {
5985
+ dateLabel = date.toLocaleDateString('en-US', {
5986
+ month: 'short',
5987
+ day: 'numeric',
5988
+ year: 'numeric',
5989
+ });
5990
+ }
5991
+ }
5992
+ return b `
5993
+ <button
5994
+ type="button"
5995
+ class=${e({
5996
+ 'history__item': true,
5997
+ 'history__item--active': conversation.conversationID === this.conversationId,
5998
+ })}
5999
+ @click=${() => this._handleSwitchConversation(conversation.conversationID)}
6000
+ >
6001
+ <span class="history__item-title">${conversation.title || 'Untitled conversation'}</span>
6002
+ ${dateLabel ? b `
6003
+ <span class="history__item-date">${dateLabel}</span>
6004
+ ` : A}
6005
+ </button>
6006
+ `;
5948
6007
  })}
5949
- @click=${() => this._handleSwitchConversation(conversation.conversationID)}
5950
- >
5951
- <span class="history__item-title">${conversation.title || 'Untitled conversation'}</span>
5952
- ${conversation.lastModified ? b `
5953
- <span class="history__item-date">${this._renderConversationDate(conversation.lastModified)}</span>
5954
- ` : A}
5955
- </button>
5956
- `)}
5957
6008
  </div>
5958
6009
  `}
5959
6010
  </div>
5960
6011
  `;
5961
6012
  }
5962
- /**
5963
- * Format a conversation's lastModified for the history list: today shows the
5964
- * time, this year shows month/day, older shows month/day/year. Falls back to the
5965
- * raw value if it isn't a parseable date.
5966
- */
5967
- _renderConversationDate(lastModified) {
5968
- const date = new Date(lastModified);
5969
- if (isNaN(date.getTime())) {
5970
- return lastModified;
5971
- }
5972
- const now = new Date();
5973
- if (date.toDateString() === now.toDateString()) {
5974
- return date.toLocaleTimeString('en-US', {
5975
- hour: 'numeric',
5976
- minute: '2-digit',
5977
- });
5978
- }
5979
- if (date.getFullYear() === now.getFullYear()) {
5980
- return date.toLocaleDateString('en-US', {
5981
- month: 'short',
5982
- day: 'numeric',
5983
- });
5984
- }
5985
- return date.toLocaleDateString('en-US', {
5986
- month: 'short',
5987
- day: 'numeric',
5988
- year: 'numeric',
5989
- });
5990
- }
5991
6013
  /**
5992
6014
  * The side drawer — one sliding panel that holds the Settings, Debug, History,
5993
6015
  * and tool-detail views. The header buttons switch `_drawerView`; the title and
@@ -6018,25 +6040,24 @@
6018
6040
  // In the floating widget the drawer fills the panel over the transcript (a
6019
6041
  // full-panel view with a back button) rather than sliding in beside it — the
6020
6042
  // panel is too narrow to show both. Inline keeps the side-drawer behavior.
6021
- const fullPanel = this.variant === 'floating';
6022
6043
  return b `
6023
6044
  <div
6024
6045
  class=${e({
6025
6046
  'drawer': true,
6026
6047
  'drawer--open': this._drawerOpen,
6027
6048
  'drawer--resizing': this._isDrawerResizing,
6028
- 'drawer--full': fullPanel,
6049
+ 'drawer--full': this.variant === 'floating',
6029
6050
  })}
6030
- style=${o(fullPanel ? {} : { width: `${this._drawerWidth}px` })}
6051
+ style=${o(this.variant === 'floating' ? {} : { width: `${this._drawerWidth}px` })}
6031
6052
  >
6032
- ${fullPanel ? A : b `
6053
+ ${this.variant === 'floating' ? A : b `
6033
6054
  <div
6034
6055
  class="drawer__resize"
6035
6056
  @mousedown=${this._handleDrawerResizeMouseDown}
6036
6057
  ></div>
6037
6058
  `}
6038
6059
  <div class="drawer__header">
6039
- ${fullPanel ? b `
6060
+ ${this.variant === 'floating' ? b `
6040
6061
  <button
6041
6062
  class="header-action"
6042
6063
  @click=${this._handleCloseDrawer}
@@ -6084,7 +6105,7 @@
6084
6105
  New conversation
6085
6106
  </kr-button>
6086
6107
  ` : A}
6087
- ${fullPanel ? A : b `
6108
+ ${this.variant === 'floating' ? A : b `
6088
6109
  <button
6089
6110
  class="header-action"
6090
6111
  @click=${this._handleCloseDrawer}
@@ -11490,17 +11511,31 @@
11490
11511
  * { id: 'activity', label: 'Activity', url: '/companies/123/activity' },
11491
11512
  * ]}
11492
11513
  * activeId="overview"
11514
+ * .actions=${[
11515
+ * { id: 'edit', label: 'Edit', variant: 'flat' },
11516
+ * { id: 'archive', label: 'Archive' },
11517
+ * { id: 'delete', label: 'Delete' },
11518
+ * { id: 'export', label: 'Export' },
11519
+ * ]}
11493
11520
  * >
11494
11521
  * <div>Screen content here</div>
11495
11522
  * </kr-screen-nav>
11496
11523
  * ```
11497
11524
  *
11525
+ * Actions render in a footer at the bottom of the nav column. With more than
11526
+ * `maxVisibleActions` (default 3), the overflow collapses into a "More" dropdown.
11527
+ * Listen for `action-click` to handle a click: `e.detail.action` is the action id.
11528
+ *
11498
11529
  * @slot - The main screen content
11499
11530
  *
11500
11531
  * @property {string} label - Main label (e.g., entity name like "Acme Corporation")
11501
11532
  * @property {string} subLabel - Sub label (e.g., entity type like "Company")
11502
11533
  * @property {KRScreenNavItem[]} items - Navigation items as JSON array
11503
11534
  * @property {string} activeId - Currently active item ID
11535
+ * @property {KRScreenNavAction[]} actions - Action buttons rendered in the nav footer
11536
+ * @property {number} maxVisibleActions - Max inline actions before collapsing into a "More" dropdown (default 3)
11537
+ *
11538
+ * @fires action-click - When an action button (inline or overflow) is clicked; detail is `{ action: id }`
11504
11539
  */
11505
11540
  class KRScreenNav extends i$3 {
11506
11541
  constructor() {
@@ -11522,6 +11557,17 @@
11522
11557
  * Can be set explicitly by the parent to override.
11523
11558
  */
11524
11559
  this.activeId = '';
11560
+ /**
11561
+ * Action buttons rendered in the nav footer.
11562
+ */
11563
+ this.actions = [];
11564
+ /**
11565
+ * Max number of inline action buttons before the overflow collapses into a
11566
+ * "More" dropdown. When actions exceed this count, the last visible slot is
11567
+ * used for the "More" button so the total footer button count never exceeds
11568
+ * this value.
11569
+ */
11570
+ this.maxVisibleActions = 3;
11525
11571
  this.handlePopstate = () => {
11526
11572
  this.syncActiveFromUrl();
11527
11573
  };
@@ -11582,7 +11628,35 @@
11582
11628
  this.activeId = match.id;
11583
11629
  }
11584
11630
  }
11631
+ handleActionClick(e, action) {
11632
+ this.dispatchEvent(new CustomEvent('action-click', {
11633
+ detail: { action: action.id },
11634
+ bubbles: true,
11635
+ composed: true,
11636
+ }));
11637
+ }
11638
+ // Fired by the "More" kr-button when an overflow option is chosen; detail is { id, label }.
11639
+ handleMoreSelect(e) {
11640
+ this.dispatchEvent(new CustomEvent('action-click', {
11641
+ detail: { action: e.detail.id },
11642
+ bubbles: true,
11643
+ composed: true,
11644
+ }));
11645
+ }
11585
11646
  render() {
11647
+ const hasOverflow = this.actions.length > this.maxVisibleActions;
11648
+ // When collapsing, the last visible slot is the "More" button, so only
11649
+ // show the first (maxVisibleActions - 1) actions inline.
11650
+ let inlineActions = this.actions;
11651
+ let overflowOptions = [];
11652
+ if (hasOverflow) {
11653
+ inlineActions = this.actions.slice(0, this.maxVisibleActions - 1);
11654
+ // Everything past the inline slots goes into the "More" dropdown.
11655
+ overflowOptions = this.actions.slice(this.maxVisibleActions - 1).map((action) => ({
11656
+ id: action.id,
11657
+ label: action.label,
11658
+ }));
11659
+ }
11586
11660
  return b `
11587
11661
  <nav class="nav">
11588
11662
  ${this.label ? b `
@@ -11609,6 +11683,31 @@
11609
11683
  `)}
11610
11684
  </div>
11611
11685
  </div>
11686
+ ${this.actions.length ? b `
11687
+ <div class="nav-actions">
11688
+ ${inlineActions.map((action) => b `
11689
+ <kr-button
11690
+ class="nav-actions__button"
11691
+ variant=${action.variant || 'outline'}
11692
+ size="small"
11693
+ @click=${(e) => this.handleActionClick(e, action)}
11694
+ >
11695
+ ${action.label}
11696
+ </kr-button>
11697
+ `)}
11698
+ ${hasOverflow ? b `
11699
+ <kr-button
11700
+ class="nav-actions__button"
11701
+ variant="outline"
11702
+ size="small"
11703
+ .options=${overflowOptions}
11704
+ @option-select=${this.handleMoreSelect}
11705
+ >
11706
+ More
11707
+ </kr-button>
11708
+ ` : A}
11709
+ </div>
11710
+ ` : A}
11612
11711
  </nav>
11613
11712
  <main class="content">
11614
11713
  <div class="content__main">
@@ -11697,6 +11796,19 @@
11697
11796
  font-weight: 700;
11698
11797
  }
11699
11798
 
11799
+ .nav-actions {
11800
+ display: flex;
11801
+ flex-direction: column;
11802
+ gap: 8px;
11803
+ padding: 12px 16px;
11804
+ border-top: 1px solid rgba(0, 0, 0, 0.08);
11805
+ flex-shrink: 0;
11806
+ }
11807
+
11808
+ .nav-actions__button {
11809
+ width: 100%;
11810
+ }
11811
+
11700
11812
  .content {
11701
11813
  flex: 1;
11702
11814
  min-width: 0;
@@ -11721,6 +11833,12 @@
11721
11833
  __decorate$2([
11722
11834
  n$1({ type: String })
11723
11835
  ], KRScreenNav.prototype, "activeId", void 0);
11836
+ __decorate$2([
11837
+ n$1({ type: Array })
11838
+ ], KRScreenNav.prototype, "actions", void 0);
11839
+ __decorate$2([
11840
+ n$1({ type: Number })
11841
+ ], KRScreenNav.prototype, "maxVisibleActions", void 0);
11724
11842
  // Guarded registration: no-op if the tag is already defined, so a second copy of krubble on
11725
11843
  // the page (e.g. the Operations shell loads krubble globally while an embedded app bundled its
11726
11844
  // own copy) doesn't throw. First definition wins.