@electerm/electerm-react 5.5.25 → 5.5.56

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.
Files changed (66) hide show
  1. package/README.md +5 -5
  2. package/client/common/bookmark-schemas.js +4 -0
  3. package/client/common/constants.js +3 -0
  4. package/client/common/create-title.jsx +5 -1
  5. package/client/common/default-setting.js +2 -0
  6. package/client/common/parse-quick-connect.js +1 -0
  7. package/client/common/parse-vv.js +119 -25
  8. package/client/common/parse-widget-log.js +57 -0
  9. package/client/common/vv-to-tab.js +120 -0
  10. package/client/components/ai/agent-tool-call-card.jsx +26 -7
  11. package/client/components/ai/agent.js +205 -45
  12. package/client/components/ai/ai-auto-compress.js +119 -0
  13. package/client/components/ai/ai-chat-history-item.jsx +133 -84
  14. package/client/components/ai/ai-chat.jsx +135 -30
  15. package/client/components/ai/ai-compress.js +95 -0
  16. package/client/components/ai/ai-config-props.js +12 -1
  17. package/client/components/ai/ai-config.jsx +43 -16
  18. package/client/components/ai/ai-config.styl +20 -0
  19. package/client/components/ai/ai-context-indicator.jsx +83 -0
  20. package/client/components/ai/ai-context.js +272 -0
  21. package/client/components/ai/ai-output.jsx +5 -6
  22. package/client/components/ai/ai-presets.js +1 -16
  23. package/client/components/ai/ai.styl +105 -6
  24. package/client/components/ai/copy-icon.jsx +31 -0
  25. package/client/components/batch-op/batch-op-runner.jsx +1 -0
  26. package/client/components/bookmark-form/bookmark-schema.js +1 -0
  27. package/client/components/bookmark-form/common/ssh-agent.jsx +31 -21
  28. package/client/components/bookmark-form/common/vv-file-field.jsx +3 -2
  29. package/client/components/bookmark-form/config/spice.js +21 -0
  30. package/client/components/bookmark-form/config/ssh.js +1 -0
  31. package/client/components/bookmark-form/fix-bookmark-default.js +1 -0
  32. package/client/components/file-transfer/transfer.jsx +3 -119
  33. package/client/components/footer/cmd-history.jsx +279 -65
  34. package/client/components/footer/cmd-history.styl +68 -1
  35. package/client/components/footer/footer-entry.jsx +7 -1
  36. package/client/components/layout/layout.jsx +12 -19
  37. package/client/components/main/main.jsx +15 -1
  38. package/client/components/quick-commands/qm.styl +58 -0
  39. package/client/components/quick-commands/quick-commands-box.jsx +180 -83
  40. package/client/components/quick-commands/quick-commands-select.jsx +11 -0
  41. package/client/components/session/session-control.jsx +35 -2
  42. package/client/components/session/session-control.styl +5 -0
  43. package/client/components/session/session.jsx +13 -0
  44. package/client/components/setting-panel/setting-common.jsx +2 -0
  45. package/client/components/setting-panel/tab-widgets.jsx +27 -7
  46. package/client/components/sftp/file-item.jsx +0 -13
  47. package/client/components/side-panel-r/side-panel-r.jsx +11 -4
  48. package/client/components/sidebar/bookmark-select.jsx +14 -2
  49. package/client/components/sidebar/history-item.jsx +20 -4
  50. package/client/components/sidebar/history.jsx +7 -0
  51. package/client/components/terminal/mixins/term-touch.js +18 -0
  52. package/client/components/tree-list/tree-list.jsx +31 -3
  53. package/client/components/widgets/widget-control.jsx +4 -2
  54. package/client/components/widgets/widget-instance-detail.jsx +370 -0
  55. package/client/components/widgets/widget-instance.jsx +30 -13
  56. package/client/components/widgets/widget-instances.jsx +3 -1
  57. package/client/components/widgets/widgets-list.jsx +8 -0
  58. package/client/components/widgets/widgets.styl +100 -0
  59. package/client/store/common.js +200 -54
  60. package/client/store/init-state.js +15 -0
  61. package/client/store/load-data.js +73 -0
  62. package/client/store/store.js +6 -0
  63. package/client/store/watch.js +3 -1
  64. package/client/store/widgets.js +23 -5
  65. package/package.json +1 -1
  66. package/client/components/file-transfer/zip.js +0 -42
@@ -138,10 +138,18 @@ export default auto(function WidgetsList ({ activeItemId, store, onClickItem })
138
138
  )
139
139
  }
140
140
 
141
+ // clicking a running instance shows its detail + log in the right column
142
+ // (the widgets list above drills into the run form instead)
143
+ const onClickWidgetInstance = (instance) => {
144
+ window.store.selectWidgetInstance(instance)
145
+ }
146
+
141
147
  const renderInstancesSection = () => {
142
148
  return (
143
149
  <WidgetInstances
144
150
  widgetInstances={widgetInstances}
151
+ activeItemId={activeItemId}
152
+ onClickItem={onClickWidgetInstance}
145
153
  />
146
154
  )
147
155
  }
@@ -0,0 +1,100 @@
1
+
2
+ // hover popover on a running-instance row. A long path makes it wider than the
3
+ // list column, and it then spills over the detail panel's heading, so it is
4
+ // held to the column and wrapped instead.
5
+ .wil-pop-info
6
+ max-width 220px
7
+ word-break break-all
8
+
9
+ // widget manager: instance detail view (widget-instance-detail.jsx)
10
+ .widget-instance-detail
11
+ display flex
12
+ flex-direction column
13
+ min-height 0
14
+ height 100%
15
+ .wil-head
16
+ display flex
17
+ align-items center
18
+ justify-content space-between
19
+ gap 8px
20
+ margin-bottom 10px
21
+ flex-shrink 0
22
+ .wil-head-title
23
+ min-width 0
24
+ font-size 14px
25
+ .wil-descriptions
26
+ // the log is the point of this panel, so a long config list gives up room
27
+ // (and scrolls) instead of squeezing it: shrinkable, and allowed to go below
28
+ // its content height, with the pane holding a floor of its own
29
+ flex 0 1 auto
30
+ min-height 0
31
+ overflow auto
32
+ // long values (paths, URLs, keys) must wrap instead of widening the panel
33
+ .ant-descriptions-item-label
34
+ width 90px
35
+ white-space nowrap
36
+ .ant-descriptions-item-content
37
+ word-break break-all
38
+ .wil-mono
39
+ font-family monospace
40
+ font-size 12px
41
+ .wil-switch
42
+ margin 10px 0 6px
43
+ flex-shrink 0
44
+ .wil-pane
45
+ flex 1
46
+ // a floor, so the log stays readable no matter how long the config list is
47
+ min-height 200px
48
+ overflow auto
49
+ padding 6px 8px
50
+ background var(--main-dark)
51
+ border 1px solid var(--main-light)
52
+ border-radius 4px
53
+ font-family monospace
54
+ font-size 12px
55
+ line-height 1.6
56
+ .wil-line
57
+ display flex
58
+ gap 6px
59
+ white-space pre-wrap
60
+ word-break break-word
61
+ border-bottom 1px solid var(--main-light)
62
+ &:last-child
63
+ border-bottom 0
64
+ .wil-time
65
+ flex-shrink 0
66
+ color var(--text-disabled)
67
+ .wil-mark
68
+ .wil-type
69
+ flex-shrink 0
70
+ .wil-from
71
+ flex-shrink 0
72
+ color var(--text-dark)
73
+ .wil-msg
74
+ min-width 0
75
+ .wil-debug
76
+ color var(--text-dark)
77
+ .wil-log
78
+ color var(--text)
79
+ .wil-warn
80
+ color var(--warn)
81
+ .wil-error
82
+ color var(--error)
83
+ .wil-ok
84
+ color var(--success)
85
+ .wil-mark
86
+ color var(--success)
87
+ .wil-fail
88
+ color var(--error)
89
+ .wil-mark
90
+ color var(--error)
91
+ .wil-info
92
+ color var(--text-dark)
93
+ .wil-empty
94
+ color var(--text-dark)
95
+ padding 6px 0
96
+ .wil-path
97
+ flex-shrink 0
98
+ margin-top 6px
99
+ color var(--text-disabled)
100
+ font-size 11px
@@ -5,15 +5,20 @@
5
5
  import handleError from '../common/error-handler'
6
6
  import Modal from '../components/common/modal'
7
7
  import { appendMandatoryGuardrails } from '../components/ai/ai-guardrails'
8
+ import { buildSessionMessages } from '../components/ai/ai-context'
9
+ import { COMPRESS_SUMMARY_PROMPT } from '../components/ai/ai-auto-compress'
8
10
  import { debounce, some, get, pickBy } from 'lodash-es'
9
11
  import {
10
12
  leftSidePanelWidthKey,
11
13
  leftSideBarOpenKey,
12
14
  rightSidebarWidthKey,
13
15
  rightPanelPinnedKey,
16
+ cmdHistoryInRightPanelKey,
17
+ quickCommandsInRightPanelKey,
14
18
  addPanelWidthLsKey,
15
19
  connectionMap,
16
20
  lastAiChatSessionIdKey,
21
+ aiAutoCompressLsKey,
17
22
  mobileBreakpoint,
18
23
  splitMap,
19
24
  settingAiId,
@@ -25,7 +30,7 @@ import { requireTermOfUse } from '../common/term-of-use'
25
30
  import { action } from 'manate'
26
31
  import uid from '../common/uid'
27
32
  import deepCopy from 'json-deep-copy'
28
- import { aiConfigsArr } from '../components/ai/ai-config-props'
33
+ import { aiConfigsArr, optionalAIConfigsArr } from '../components/ai/ai-config-props'
29
34
 
30
35
  const e = window.translate
31
36
  const { assign } = Object
@@ -179,6 +184,100 @@ export default Store => {
179
184
  ls.setItem(rightPanelPinnedKey, v + '')
180
185
  window.store.rightPanelPinned = v
181
186
  }
187
+
188
+ // The cmd history panel has two homes: the footer popover (the default) and
189
+ // the right side panel. Which one it is in is a durable preference, persisted
190
+ // like the right panel pin — the footer trigger has to keep landing where the
191
+ // user last put the panel, or the choice would only survive until the next
192
+ // reload.
193
+ Store.prototype.setCmdHistoryInRightPanel = function (v) {
194
+ ls.setItem(cmdHistoryInRightPanelKey, v + '')
195
+ window.store.cmdHistoryInRightPanel = v
196
+ }
197
+
198
+ Store.prototype.openCmdHistoryPanel = function () {
199
+ const { store } = window
200
+ store.rightPanelVisible = true
201
+ store.rightPanelTab = 'cmdHistory'
202
+ }
203
+
204
+ // Same toggle contract as toggleInfoPanel/toggleAIPanel: while the history
205
+ // lives in the right panel, the footer trigger opens and closes it.
206
+ Store.prototype.toggleCmdHistoryPanel = function () {
207
+ const { store } = window
208
+ if (store.rightPanelVisible && store.rightPanelTab === 'cmdHistory') {
209
+ store.rightPanelVisible = false
210
+ return
211
+ }
212
+ store.openCmdHistoryPanel()
213
+ }
214
+
215
+ Store.prototype.moveCmdHistoryToRightPanel = function () {
216
+ const { store } = window
217
+ store.setCmdHistoryInRightPanel(true)
218
+ store.openCmdHistoryPanel()
219
+ }
220
+
221
+ // Hand the panel back to the footer popover and open it there: the move has
222
+ // to be visible, otherwise the panel just looks like it vanished.
223
+ Store.prototype.moveCmdHistoryToFooter = function () {
224
+ const { store } = window
225
+ store.setCmdHistoryInRightPanel(false)
226
+ if (store.rightPanelTab === 'cmdHistory') {
227
+ store.rightPanelVisible = false
228
+ }
229
+ refsStatic.get('CmdHistory')?.openPopover()
230
+ }
231
+
232
+ // The quick command panel has the same two homes as the cmd history one, and
233
+ // the same durable preference: the footer popup (the default) and the right
234
+ // side panel. The footer popup is not a popover but a floating box driven by
235
+ // store.openQuickCommandBar, so "docked" is expressed by the preference alone
236
+ // and the box itself decides which of its two forms to render.
237
+ Store.prototype.setQuickCommandsInRightPanel = function (v) {
238
+ ls.setItem(quickCommandsInRightPanelKey, v + '')
239
+ window.store.quickCommandsInRightPanel = v
240
+ }
241
+
242
+ Store.prototype.openQuickCommandsPanel = function () {
243
+ const { store } = window
244
+ store.rightPanelVisible = true
245
+ store.rightPanelTab = 'quickCommands'
246
+ }
247
+
248
+ // Same toggle contract as toggleInfoPanel/toggleAIPanel/toggleCmdHistoryPanel:
249
+ // while the panel lives in the right panel, the footer Q opens and closes it.
250
+ Store.prototype.toggleQuickCommandsPanel = function () {
251
+ const { store } = window
252
+ if (store.rightPanelVisible && store.rightPanelTab === 'quickCommands') {
253
+ store.rightPanelVisible = false
254
+ return
255
+ }
256
+ store.openQuickCommandsPanel()
257
+ }
258
+
259
+ Store.prototype.moveQuickCommandsToRightPanel = function () {
260
+ const { store } = window
261
+ store.setQuickCommandsInRightPanel(true)
262
+ // the footer box would otherwise be left floating over a terminal it no
263
+ // longer owns; the pin is deliberately kept, so handing the panel back
264
+ // restores the exact shape it had before
265
+ store.openQuickCommandBar = false
266
+ store.openQuickCommandsPanel()
267
+ }
268
+
269
+ // Hand the panel back to the footer and open it there: the move has to be
270
+ // visible, otherwise the panel just looks like it vanished. A pin that was
271
+ // left on while docked brings the box back pinned, which is what the user
272
+ // last asked for.
273
+ Store.prototype.moveQuickCommandsToFooter = function () {
274
+ const { store } = window
275
+ store.setQuickCommandsInRightPanel(false)
276
+ if (store.rightPanelTab === 'quickCommands') {
277
+ store.rightPanelVisible = false
278
+ }
279
+ store.openQuickCommandBar = true
280
+ }
182
281
  Store.prototype.beforeExit = function (evt) {
183
282
  const { confirmBeforeExit } = window.store.config
184
283
  if (
@@ -347,13 +446,33 @@ export default Store => {
347
446
  })
348
447
  }
349
448
 
449
+ // Update one entry immutably. Both halves matter:
450
+ // - the store only notifies subscribers on a property write, so mutating the
451
+ // entry in place leaves the panel rendering what it already had;
452
+ // - the transcript is memo()'d per entry, and a memo() comparison of an entry
453
+ // that was mutated in place compares the object with itself, so React can
454
+ // never see the change and the entry stops updating.
455
+ Store.prototype.updateAiHistoryEntry = function (id, updates) {
456
+ const { aiChatHistory } = window.store
457
+ const index = aiChatHistory.findIndex(d => d.id === id)
458
+ if (index === -1) {
459
+ return
460
+ }
461
+ const next = [...aiChatHistory]
462
+ next[index] = { ...next[index], ...updates }
463
+ window.store.aiChatHistory = next
464
+ }
465
+
350
466
  Store.prototype.removeAiHistory = function (id) {
351
467
  const { store } = window
352
468
  const index = store.aiChatHistory.findIndex(d => d.id === id)
353
469
  if (index === -1) {
354
470
  return
355
471
  }
356
- window.store.aiChatHistory.splice(index, 1)
472
+ // Reassign instead of splice: the store only notifies subscribers on a
473
+ // property write, so an in-place mutation leaves the panel rendering the
474
+ // list it already had.
475
+ window.store.aiChatHistory = store.aiChatHistory.filter(d => d.id !== id)
357
476
  }
358
477
 
359
478
  Store.prototype.startNewChat = action(function () {
@@ -386,8 +505,17 @@ export default Store => {
386
505
  store.startNewChat()
387
506
  })
388
507
 
508
+ // One compression per session at a time. The placeholder entry is published
509
+ // before the summary arrives, so a second call (auto compress firing while a
510
+ // manual one is still waiting on the provider) would summarize the
511
+ // placeholder and append a second one.
512
+ const compressingSessions = new Set()
513
+
389
514
  Store.prototype.compressChatSession = async function (sessionId) {
390
515
  const { store } = window
516
+ if (!sessionId || compressingSessions.has(sessionId)) {
517
+ return
518
+ }
391
519
  const sessionEntries = store.aiChatHistory
392
520
  .filter(h => h.chatSessionId === sessionId)
393
521
  .sort((a, b) => a.timestamp - b.timestamp)
@@ -411,60 +539,27 @@ export default Store => {
411
539
 
412
540
  const firstEntry = sessionEntries[0]
413
541
  const lang = firstEntry.languageAI || store.getLangName()
414
- const messages = [
415
- { role: 'system', content: appendMandatoryGuardrails(firstEntry.roleAI + `;用[${lang}]回复`) }
416
- ]
417
-
418
- // Start from the last compress entry to include its summary as context
419
- const startIndex = lastCompressIndex >= 0 ? lastCompressIndex : 0
420
- for (let i = startIndex; i < sessionEntries.length; i++) {
421
- const entry = sessionEntries[i]
422
- if (entry.compressed) {
423
- messages.push({
424
- role: 'user',
425
- content: `Here is a summary of our previous conversation for context:\n\n${entry.response}`
426
- })
427
- messages.push({
428
- role: 'assistant',
429
- content: 'Understood. I will use this context as we continue.'
430
- })
431
- } else {
432
- messages.push({
433
- role: 'user',
434
- content: entry.promptWithAttachments || entry.prompt
435
- })
436
- if (entry.response) {
437
- messages.push({ role: 'assistant', content: entry.response })
438
- }
439
- }
440
- }
441
-
442
- const summaryPrompt = 'Please summarize the above conversation concisely. Include key information, decisions, context, and any important details that would be needed to continue this conversation effectively.'
443
- messages.push({ role: 'user', content: summaryPrompt })
542
+ // Same message list the chat turn would send (including the response of
543
+ // every entry), so the summary describes the conversation the model
544
+ // actually saw.
545
+ const messages = buildSessionMessages({
546
+ history: store.aiChatHistory,
547
+ chatSessionId: sessionId,
548
+ role: appendMandatoryGuardrails(firstEntry.roleAI + `;用[${lang}]回复`)
549
+ })
444
550
 
445
- const aiResponse = await window.pre.runGlobalAsync(
446
- 'AIchat',
447
- summaryPrompt,
448
- firstEntry.modelAI,
449
- firstEntry.roleAI,
450
- firstEntry.baseURLAI,
451
- firstEntry.apiPathAI,
452
- firstEntry.apiKeyAI,
453
- firstEntry.proxyAI,
454
- false,
455
- firstEntry.authHeaderNameAI,
456
- messages
457
- )
551
+ // The same closing request the agent loop's auto compression sends (see
552
+ // components/ai/ai-compress.js), so both paths summarize alike.
553
+ messages.push({ role: 'user', content: COMPRESS_SUMMARY_PROMPT })
458
554
 
459
- if (aiResponse && aiResponse.error) {
460
- return store.onError(new Error(aiResponse.error))
461
- }
462
-
463
- const summary = aiResponse.response || ''
555
+ // Show the entry before asking for the summary, and fill it in when the
556
+ // answer arrives. The request takes as long as any other AI request (much
557
+ // longer for a nearly full session), and a click that changes nothing on
558
+ // screen for that long reads as "compress did not work".
464
559
  const compressedEntry = {
465
560
  id: uid(),
466
561
  prompt: '*Compressed session summary*',
467
- response: summary,
562
+ response: '*Summarizing the session…*',
468
563
  isStreaming: false,
469
564
  pending: false,
470
565
  sessionId: null,
@@ -484,10 +579,59 @@ export default Store => {
484
579
  compressed: true
485
580
  }
486
581
 
487
- // Append compress entry, preserve existing history
488
- store.aiChatHistory.push(compressedEntry)
582
+ // Append compress entry, preserve existing history. Reassign rather than
583
+ // push for the same reason as removeAiHistory -- the panel has to see the
584
+ // new context size, which is the whole point of compressing.
585
+ store.aiChatHistory = [...store.aiChatHistory, compressedEntry]
586
+ compressingSessions.add(sessionId)
587
+
588
+ // Anything short of a usable summary takes the placeholder away again: an
589
+ // empty summary must never replace the conversation, and a half finished
590
+ // one must not look like a result.
591
+ const fail = (msg) => {
592
+ store.removeAiHistory(compressedEntry.id)
593
+ return store.onError(new Error(msg))
594
+ }
595
+
596
+ try {
597
+ const aiResponse = await window.pre.runGlobalAsync(
598
+ 'AIchat',
599
+ COMPRESS_SUMMARY_PROMPT,
600
+ firstEntry.modelAI,
601
+ firstEntry.roleAI,
602
+ firstEntry.baseURLAI,
603
+ firstEntry.apiPathAI,
604
+ firstEntry.apiKeyAI,
605
+ firstEntry.proxyAI,
606
+ false,
607
+ firstEntry.authHeaderNameAI,
608
+ messages
609
+ )
610
+
611
+ if (aiResponse && aiResponse.error) {
612
+ return fail(aiResponse.error)
613
+ }
614
+
615
+ const summary = (aiResponse && aiResponse.response) || ''
616
+ if (!summary.trim()) {
617
+ return fail('Compression failed: empty summary from AI')
618
+ }
619
+ store.updateAiHistoryEntry(compressedEntry.id, { response: summary })
620
+ } finally {
621
+ compressingSessions.delete(sessionId)
622
+ }
489
623
  }
490
624
 
625
+ // Auto compression, both modes: ask turns and the agent loop read this flag
626
+ // before every request (see components/ai/ai-compress.js), and the panel's
627
+ // auto compress popover writes it. Persisted so the choice survives a reload.
628
+ Store.prototype.setAiAutoCompress = action(function (val) {
629
+ const { store } = window
630
+ const on = !!val
631
+ store.aiAutoCompress = on
632
+ ls.setItem(aiAutoCompressLsKey, on ? 'true' : 'false')
633
+ })
634
+
491
635
  Store.prototype.toggleChatSessions = action(function () {
492
636
  const { store } = window
493
637
  store.showChatSessions = !store.showChatSessions
@@ -566,7 +710,9 @@ export default Store => {
566
710
  }
567
711
 
568
712
  Store.prototype.aiConfigMissing = function () {
569
- return aiConfigsArr.filter(k => k !== 'apiKeyAI' && k !== 'proxyAI' && k !== 'nameAI').some(k => !window.store.config[k])
713
+ return aiConfigsArr
714
+ .filter(k => !optionalAIConfigsArr.includes(k))
715
+ .some(k => !window.store.config[k])
570
716
  }
571
717
 
572
718
  Store.prototype.clearHistory = function () {
@@ -19,12 +19,15 @@ import {
19
19
  leftSidePanelWidthKey,
20
20
  rightSidebarWidthKey,
21
21
  rightPanelPinnedKey,
22
+ cmdHistoryInRightPanelKey,
23
+ quickCommandsInRightPanelKey,
22
24
  addPanelWidthLsKey,
23
25
  qmSortByFrequencyKey,
24
26
  resolutionsLsKey,
25
27
  syncServerDataKey,
26
28
  splitMap,
27
29
  lastAiChatSessionIdKey,
30
+ aiAutoCompressLsKey,
28
31
  mobileBreakpoint
29
32
  } from '../common/constants'
30
33
  import * as ls from '../common/safe-local-storage'
@@ -95,6 +98,14 @@ export default () => {
95
98
  _batchInputSelectedTabIds: new Set(),
96
99
  aiChatHistory: [],
97
100
  agentRunning: false,
101
+ // ask turns and the agent loop compact their conversation when the window
102
+ // gets full; persisted here so the toggle survives a reload
103
+ // (see components/ai/ai-compress.js)
104
+ aiAutoCompress: ls.getItem(aiAutoCompressLsKey) === 'true',
105
+ // live context accounting while an agent loop is running: the agent
106
+ // carries tool schemas and tool results that the session history does
107
+ // not, so it publishes its own figure here (see components/ai/agent.js)
108
+ aiContextInfo: null,
98
109
  currentChatSessionId: window.localStorage.getItem(lastAiChatSessionIdKey) || '',
99
110
  showChatSessions: false,
100
111
 
@@ -127,6 +138,8 @@ export default () => {
127
138
  rightPanelVisible: false,
128
139
  rightPanelTab: 'info',
129
140
  rightPanelPinned: ls.getItem(rightPanelPinnedKey) === 'true',
141
+ cmdHistoryInRightPanel: ls.getItem(cmdHistoryInRightPanelKey) === 'true',
142
+ quickCommandsInRightPanel: ls.getItem(quickCommandsInRightPanelKey) === 'true',
130
143
  _rightPanelWidth: parseInt(ls.getItem(rightSidebarWidthKey), 10) || 500,
131
144
  showAIConfigModal: false,
132
145
 
@@ -212,6 +225,8 @@ export default () => {
212
225
  exePath: '',
213
226
  isPortable: false,
214
227
  installSrc: '',
228
+ // where running widgets write their logs, from the main process
229
+ widgetLogPath: '',
215
230
  showSshConfigModal: false,
216
231
 
217
232
  // batch inputs
@@ -14,6 +14,10 @@ import { initWsCommon } from '../common/fetch-from-server'
14
14
  import safeParse from '../common/parse-json-safe'
15
15
  import initWatch from './watch'
16
16
  import { parseQuickConnect } from '../common/parse-quick-connect'
17
+ import message from '../components/common/message'
18
+ import fs from '../common/fs'
19
+ import { parseVv, describeVv } from '../common/parse-vv'
20
+ import { vvToTab } from '../common/vv-to-tab'
17
21
 
18
22
  function getHost (argv, opts) {
19
23
  const arr = argv
@@ -35,6 +39,56 @@ function getHost (argv, opts) {
35
39
  return {}
36
40
  }
37
41
 
42
+ /**
43
+ * Open a .vv (virt-viewer) connection file as a new Spice session.
44
+ *
45
+ * Used when electerm is handed a file from outside:
46
+ * electerm /path/to/console.vv
47
+ *
48
+ * This opens a session, it does not import a bookmark. For a Proxmox file that
49
+ * is the only shape that can work at all: the signed proxy ticket it carries is
50
+ * refused by the node's :3128 daemon once it is more than about 40 seconds old,
51
+ * so a bookmark saved from one could never connect.
52
+ *
53
+ * The parse itself is shared with the bookmark form's "Load .vv file" button --
54
+ * src/client/common/parse-vv.js is the single implementation.
55
+ *
56
+ * @param {Object} store
57
+ * @param {string} filePath - absolute path, resolved in the main process
58
+ * @returns {Promise<Object|null>} the parse result, or null on failure
59
+ */
60
+ export async function openVvFile (store, filePath) {
61
+ console.debug('opening .vv file', filePath)
62
+ let text
63
+ try {
64
+ // fs.readFile resolves the file as a utf8 string (src/app/lib/fs.js)
65
+ text = await fs.readFile(filePath)
66
+ } catch (err) {
67
+ message.error('cannot read ' + filePath + ': ' + err.message)
68
+ return null
69
+ }
70
+ const parsed = parseVv(text)
71
+ if (!parsed.ok) {
72
+ message.error(describeVv(parsed) + ' (' + filePath + ')')
73
+ return null
74
+ }
75
+ const tab = vvToTab(parsed, { filePath })
76
+ console.debug('.vv file -> tab', tab)
77
+ // A file that did not come from a person clicking through a form gets no
78
+ // chance to read a hint, so anything dropped from it has to be said out loud.
79
+ if (parsed.warnings.length) {
80
+ console.debug('.vv warnings', parsed.warnings)
81
+ }
82
+ if (parsed.ignored.length) {
83
+ message.warning(
84
+ parsed.ignored.length + ' option(s) in this .vv were skipped: ' +
85
+ parsed.ignored.map(d => d.key).join(', ')
86
+ )
87
+ }
88
+ store.ipcOpenTab(tab)
89
+ return parsed
90
+ }
91
+
38
92
  export async function addTabFromCommandLine (store, opts) {
39
93
  console.debug('command line params', opts)
40
94
  if (!opts) {
@@ -52,6 +106,12 @@ export async function addTabFromCommandLine (store, opts) {
52
106
  if (isHelp) {
53
107
  return store.openAbout(infoTabs.cmd)
54
108
  }
109
+ // A .vv connection file, e.g. `electerm /path/to/console.vv`.
110
+ // Checked before the protocol-URL scan below because the two cannot overlap:
111
+ // a path ending in .vv never matches a `scheme://` prefix.
112
+ if (opts.vvFile) {
113
+ return openVvFile(store, opts.vvFile)
114
+ }
55
115
  // Check if argv contains a protocol URL (e.g., ssh://user@host)
56
116
  // and use parseQuickConnect for proper parsing
57
117
  if (argv && argv.length) {
@@ -216,6 +276,7 @@ export default (Store) => {
216
276
  store.appPath = globs.appPath
217
277
  store.exePath = globs.exePath
218
278
  store.isPortable = globs.isPortable
279
+ store.widgetLogPath = globs.widgetLogPath || ''
219
280
  store._config = globs.config
220
281
  window.et.langs = globs.langs
221
282
  store.zoom(store.config.zoom, false, true)
@@ -297,6 +358,18 @@ export default (Store) => {
297
358
  window.store.ipcOpenTab(pending)
298
359
  }
299
360
  }
361
+ /**
362
+ * A .vv file that arrived before the window existed -- macOS delivers
363
+ * 'open-file' during launch, so a cold double-click always lands here. The
364
+ * payload is the same shape initCommandLine() returns, so it goes through the
365
+ * same entry point as `electerm /path/to/console.vv`.
366
+ */
367
+ Store.prototype.checkPendingVvFile = async function () {
368
+ const pending = await window.pre.runGlobalAsync('getPendingVvFile')
369
+ if (pending) {
370
+ addTabFromCommandLine(window.store, pending)
371
+ }
372
+ }
300
373
  Store.prototype.parseQuickConnect = function (url) {
301
374
  return parseQuickConnect(url)
302
375
  }
@@ -145,6 +145,12 @@ class Store {
145
145
  if (rightPanelTab === 'ai') {
146
146
  return getBrand(baseURLAI).brand || 'Custom AI Model'
147
147
  }
148
+ if (rightPanelTab === 'cmdHistory') {
149
+ return window.translate('cmdHistory')
150
+ }
151
+ if (rightPanelTab === 'quickCommands') {
152
+ return window.translate('quickCommands')
153
+ }
148
154
  return createTitle(window.store.currentTab)
149
155
  }
150
156
 
@@ -151,7 +151,9 @@ export default store => {
151
151
  window.pre.runGlobalAsync('setTitle', title)
152
152
  window.store.currentLayoutBatch = tab.batch
153
153
  }
154
- if (tab && store.rightPanelVisible) {
154
+ // refreshing the info panel on tab switch only concerns the info tab: the
155
+ // right panel can also be showing the AI chat or the cmd history
156
+ if (tab && store.rightPanelVisible && store.rightPanelTab === 'info') {
155
157
  window.store.openInfoPanelAction()
156
158
  }
157
159
  return store.activeTabId
@@ -4,7 +4,8 @@
4
4
 
5
5
  import message from '../components/common/message'
6
6
  import {
7
- settingMap
7
+ settingMap,
8
+ settingPanelMobileBreakpoint
8
9
  } from '../common/constants'
9
10
  import getInitItem from '../common/init-setting-item'
10
11
  import deepCopy from 'json-deep-copy'
@@ -27,12 +28,28 @@ export default Store => {
27
28
  const {
28
29
  widgetInstances
29
30
  } = window.store
30
- const index = widgetInstances.findIndex(w => w.id === instanceId)
31
- if (index > -1) {
32
- widgetInstances.splice(index, 1)
31
+ // assigned, not spliced: an in-place mutation fires no subscriber, and the
32
+ // instance list, the sidebar badge and the detail panel all read this array
33
+ window.store.widgetInstances = widgetInstances.filter(w => w.id !== instanceId)
34
+ }
35
+
36
+ // Selecting a running instance in the widgets list shows its detail + log in
37
+ // the right column, the same way selecting a widget shows its run form.
38
+ Store.prototype.selectWidgetInstance = (instance) => {
39
+ const { store } = window
40
+ store.setSettingItem(instance)
41
+ // narrow panels drill into the right col content after picking an instance
42
+ if (window.store.innerWidth <= settingPanelMobileBreakpoint) {
43
+ window.store.settingMobileView = 'content'
33
44
  }
34
45
  }
35
46
 
47
+ // back to the widget form — used when the instance being viewed is gone
48
+ Store.prototype.closeWidgetInstance = () => {
49
+ const { store } = window
50
+ store.setSettingItem(getInitItem([], settingMap.widgets))
51
+ }
52
+
36
53
  Store.prototype.stopWidget = async (instanceId) => {
37
54
  const {
38
55
  store
@@ -102,10 +119,11 @@ export default Store => {
102
119
  widgetId: result.widgetId,
103
120
  serverInfo: result.serverInfo,
104
121
  config: item.config,
122
+ startedAt: Date.now(),
105
123
  autoRun: true,
106
124
  autoRunId: item.id
107
125
  }
108
- store.widgetInstances.push(instance)
126
+ store.widgetInstances = [...store.widgetInstances, instance]
109
127
  }
110
128
  } catch (err) {
111
129
  console.error(`Failed to autorun widget ${item.widgetId}:`, err)