fraim-framework 2.0.162 → 2.0.164
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/src/ai-hub/desktop-main.js +4 -1
- package/dist/src/ai-hub/hosts.js +97 -12
- package/dist/src/ai-hub/preferences.js +1 -1
- package/dist/src/ai-hub/server.js +49 -123
- package/dist/src/cli/commands/init-project.js +15 -14
- package/dist/src/cli/commands/sync.js +38 -0
- package/dist/src/cli/doctor/check-runner.js +3 -1
- package/dist/src/cli/doctor/checks/mcp-connectivity-checks.js +261 -2
- package/dist/src/cli/utils/git-org-sync.js +56 -0
- package/dist/src/cli/utils/org-migration.js +50 -0
- package/dist/src/cli/utils/org-pack-sync.js +208 -0
- package/dist/src/cli/utils/project-bootstrap.js +20 -7
- package/dist/src/cli/utils/user-config.js +68 -0
- package/dist/src/core/fraim-config-schema.generated.js +10 -0
- package/dist/src/first-run/types.js +8 -0
- package/dist/src/local-mcp-server/agent-token-prices.js +30 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +78 -29
- package/dist/src/local-mcp-server/stdio-server.js +30 -0
- package/index.js +1 -1
- package/package.json +2 -3
- package/public/ai-hub/index.html +5 -5
- package/public/ai-hub/powerpoint-taskpane/index.html +236 -236
- package/public/ai-hub/powerpoint-taskpane/manifest.xml +29 -29
- package/public/ai-hub/review.css +15 -15
- package/public/ai-hub/script.js +254 -195
- package/public/ai-hub/styles.css +206 -16
- package/public/first-run/styles.css +73 -73
- package/dist/src/ai-hub/word-sideload.js +0 -95
- package/dist/src/cli/commands/test-mcp.js +0 -171
- package/dist/src/cli/setup/first-run.js +0 -242
- package/dist/src/core/config-writer.js +0 -75
- package/dist/src/core/utils/job-aliases.js +0 -47
- package/dist/src/core/utils/workflow-parser.js +0 -174
package/public/ai-hub/script.js
CHANGED
|
@@ -9,8 +9,6 @@
|
|
|
9
9
|
// conversation maps to one job and (when active) one backend run id. The
|
|
10
10
|
// existing single-active-run backend stays untouched per spec R15.
|
|
11
11
|
|
|
12
|
-
const STORAGE_KEY_CONVERSATIONS = 'fraim.aiHub.conversations.v1';
|
|
13
|
-
const STORAGE_KEY_ACTIVE = 'fraim.aiHub.activeConversation.v1';
|
|
14
12
|
const STORAGE_KEY_TREE_WIDTH = 'fraim.aiHub.treeSidebarWidth.v1';
|
|
15
13
|
const STORAGE_KEY_TREE_COLLAPSED = 'fraim.aiHub.treeSidebarCollapsed.v1';
|
|
16
14
|
const TREE_WIDTH_MIN = 176;
|
|
@@ -386,35 +384,6 @@ function normalizeGeminiConversationMessages(conv) {
|
|
|
386
384
|
return changed;
|
|
387
385
|
}
|
|
388
386
|
|
|
389
|
-
function loadConversationsFromStorage() {
|
|
390
|
-
try {
|
|
391
|
-
const raw = window.localStorage.getItem(STORAGE_KEY_CONVERSATIONS);
|
|
392
|
-
state.conversations = raw ? JSON.parse(raw) : {};
|
|
393
|
-
} catch {
|
|
394
|
-
state.conversations = {};
|
|
395
|
-
}
|
|
396
|
-
try {
|
|
397
|
-
state.activeId = window.localStorage.getItem(STORAGE_KEY_ACTIVE) || null;
|
|
398
|
-
} catch {
|
|
399
|
-
state.activeId = null;
|
|
400
|
-
}
|
|
401
|
-
// Retroactively fix titles that were saved as the generic "New job" fallback
|
|
402
|
-
// by re-deriving from the first manager message.
|
|
403
|
-
let normalizedLegacyConversations = false;
|
|
404
|
-
for (const convList of Object.values(state.conversations)) {
|
|
405
|
-
for (const conv of convList) {
|
|
406
|
-
normalizedLegacyConversations = normalizeGeminiConversationMessages(conv) || normalizedLegacyConversations;
|
|
407
|
-
if (conv.title === 'New job') {
|
|
408
|
-
const firstMsg = (conv.messages || []).find((m) => m.role === 'manager');
|
|
409
|
-
if (!firstMsg) continue;
|
|
410
|
-
const rederived = deriveTitle(conv.jobTitle || '', firstMsg.text || '');
|
|
411
|
-
if (rederived !== 'New job') conv.title = rederived;
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
if (normalizedLegacyConversations) persistConversations();
|
|
416
|
-
}
|
|
417
|
-
|
|
418
387
|
function projectConversationPayload() {
|
|
419
388
|
return {
|
|
420
389
|
projectPath: state.projectPath || '',
|
|
@@ -423,19 +392,6 @@ function projectConversationPayload() {
|
|
|
423
392
|
};
|
|
424
393
|
}
|
|
425
394
|
|
|
426
|
-
function persistConversationsToCache() {
|
|
427
|
-
try {
|
|
428
|
-
window.localStorage.setItem(STORAGE_KEY_CONVERSATIONS, JSON.stringify(state.conversations));
|
|
429
|
-
if (state.activeId) {
|
|
430
|
-
window.localStorage.setItem(STORAGE_KEY_ACTIVE, state.activeId);
|
|
431
|
-
} else {
|
|
432
|
-
window.localStorage.removeItem(STORAGE_KEY_ACTIVE);
|
|
433
|
-
}
|
|
434
|
-
} catch (error) {
|
|
435
|
-
console.warn('Could not persist conversations:', error);
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
|
|
439
395
|
function scheduleConversationDiskPersist() {
|
|
440
396
|
if (!state.projectPath) return;
|
|
441
397
|
if (state.conversationPersistTimer) window.clearTimeout(state.conversationPersistTimer);
|
|
@@ -457,7 +413,6 @@ function scheduleConversationDiskPersist() {
|
|
|
457
413
|
|
|
458
414
|
function persistConversations(options) {
|
|
459
415
|
const opts = options || {};
|
|
460
|
-
persistConversationsToCache();
|
|
461
416
|
if (opts.disk !== false) scheduleConversationDiskPersist();
|
|
462
417
|
}
|
|
463
418
|
|
|
@@ -465,33 +420,17 @@ async function hydrateConversationsFromServer() {
|
|
|
465
420
|
if (!state.projectPath) return;
|
|
466
421
|
try {
|
|
467
422
|
const payload = await requestJson(`/api/ai-hub/conversations?projectPath=${encodeURIComponent(state.projectPath)}`);
|
|
468
|
-
const
|
|
469
|
-
const localConversations = projectConversations();
|
|
470
|
-
const mergedById = new Map();
|
|
471
|
-
for (const conv of serverConversations) mergedById.set(conv.id, conv);
|
|
472
|
-
for (const conv of localConversations) {
|
|
473
|
-
const existing = mergedById.get(conv.id);
|
|
474
|
-
if (!existing || conversationTimestamp(conv) > conversationTimestamp(existing)) {
|
|
475
|
-
mergedById.set(conv.id, conv);
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
const conversations = Array.from(mergedById.values())
|
|
479
|
-
.sort((a, b) => conversationTimestamp(b) - conversationTimestamp(a));
|
|
480
|
-
// #534: apply the same Gemini stdout/stderr normalization as the localStorage
|
|
481
|
-
// path so server-hydrated conversations render identically (the localStorage
|
|
482
|
-
// path normalizes in loadConversationsFromStorage; without this, a hydrate-vs-
|
|
483
|
-
// load race can surface uncoalesced/stale rows).
|
|
423
|
+
const conversations = Array.isArray(payload.conversations) ? payload.conversations : [];
|
|
484
424
|
for (const conv of conversations) normalizeGeminiConversationMessages(conv);
|
|
485
425
|
state.conversations[state.projectPath] = conversations;
|
|
486
426
|
const activeCandidates = [state.activeId, payload.activeId].filter(Boolean);
|
|
487
427
|
state.activeId = activeCandidates.find((id) => conversations.some((conv) => conv.id === id)) || (conversations[0] ? conversations[0].id : null);
|
|
488
428
|
state.conversationDiskAvailable = true;
|
|
489
|
-
persistConversations({ disk: false });
|
|
490
429
|
renderRail();
|
|
491
430
|
renderActive();
|
|
492
431
|
} catch (error) {
|
|
493
432
|
state.conversationDiskAvailable = false;
|
|
494
|
-
console.warn('Could not hydrate conversations from
|
|
433
|
+
console.warn('Could not hydrate conversations from server:', error);
|
|
495
434
|
}
|
|
496
435
|
}
|
|
497
436
|
|
|
@@ -600,10 +539,18 @@ function stripReviewHandoffBlocks(text) {
|
|
|
600
539
|
.trim();
|
|
601
540
|
}
|
|
602
541
|
|
|
542
|
+
function stripHubInjectedPromptBlocks(text) {
|
|
543
|
+
return String(text || '')
|
|
544
|
+
.replace(/(?:^|\n)\s*\[How to talk to me\][\s\S]*?(?=\n\s*\[[^\]\n]+\]|\n\s*#{1,6}\s|\n\s*[-*]\s|\n{2,}|$)/gi, '\n')
|
|
545
|
+
.replace(/(?:^|\n)\s*(?:[$/]fraim)\s+[a-z0-9-]+(?:\s*\[[^\]\n]*\])?[^\n]*(?=\n|$)/gi, '\n')
|
|
546
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
547
|
+
.trim();
|
|
548
|
+
}
|
|
549
|
+
|
|
603
550
|
// R8: render markdown subset safely. HTML is escaped first.
|
|
604
551
|
function formatEmployeeText(text) {
|
|
605
552
|
if (!text) return '';
|
|
606
|
-
const visibleText = stripReviewHandoffBlocks(text);
|
|
553
|
+
const visibleText = stripHubInjectedPromptBlocks(stripReviewHandoffBlocks(text));
|
|
607
554
|
if (!visibleText) return '';
|
|
608
555
|
// 1. Escape HTML entities.
|
|
609
556
|
let s = visibleText
|
|
@@ -767,14 +714,72 @@ function renderRail() {
|
|
|
767
714
|
!(inWorkspace && projectUpdateJobs.has(conv.jobId))
|
|
768
715
|
);
|
|
769
716
|
|
|
717
|
+
// Issue #550: Two-path routing for ad-hoc (freeform) conversations.
|
|
718
|
+
// Path A: jobId === '__freeform__' AND conv.personaKey resolves to a FRAIM
|
|
719
|
+
// employee the user has access to -> groups with that employee's
|
|
720
|
+
// accordion, identical to a structured catalog run.
|
|
721
|
+
// Path B: jobId === '__freeform__' AND no personaKey (no FRAIM employee
|
|
722
|
+
// identity was resolved) -> deferred to a single "Watercooler
|
|
723
|
+
// Conversations" group rendered after all employee groups (R3).
|
|
724
|
+
// The agentName (host CLI) is NOT a FRAIM employee and does not
|
|
725
|
+
// qualify an ad-hoc run for Path A — only personaKey does.
|
|
726
|
+
const watercoolerConvs = [];
|
|
770
727
|
const groups = new Map();
|
|
771
728
|
for (const conv of list) {
|
|
729
|
+
const isFreeform = conv.jobId === '__freeform__';
|
|
730
|
+
if (isFreeform && !conv.personaKey) {
|
|
731
|
+
// Path B: unmatched ad-hoc — collect for the Watercooler group.
|
|
732
|
+
watercoolerConvs.push(conv);
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
// Path A (matched ad-hoc with personaKey) or structured run:
|
|
736
|
+
// group by employee key as before.
|
|
772
737
|
const key = conv.personaKey || conversationAgentName(conv) || 'free';
|
|
773
738
|
const label = getConversationEmployeeLabel(conv);
|
|
774
739
|
if (!groups.has(key)) groups.set(key, { key, label, detail: getConversationEmployeeDetail(conv), sample: conv, conversations: [] });
|
|
775
740
|
groups.get(key).conversations.push(conv);
|
|
776
741
|
}
|
|
777
742
|
|
|
743
|
+
// Helper: build the run-item buttons shared by employee groups and Watercooler.
|
|
744
|
+
function buildGroupList(conversations) {
|
|
745
|
+
const groupList = document.createElement('div');
|
|
746
|
+
groupList.className = 'conv-employee-list';
|
|
747
|
+
for (const conv of conversations) {
|
|
748
|
+
const btn = document.createElement('button');
|
|
749
|
+
btn.type = 'button';
|
|
750
|
+
btn.className = 'conv-item' + (state.activeId === conv.id ? ' active' : '');
|
|
751
|
+
btn.dataset.conv = conv.id;
|
|
752
|
+
const bodyDiv = document.createElement('span');
|
|
753
|
+
bodyDiv.className = 'conv-body';
|
|
754
|
+
const titleSpan = document.createElement('span');
|
|
755
|
+
titleSpan.className = 'conv-title';
|
|
756
|
+
titleSpan.textContent = conv.title || '';
|
|
757
|
+
bodyDiv.appendChild(titleSpan);
|
|
758
|
+
btn.appendChild(bodyDiv);
|
|
759
|
+
const dotClass = conversationStateDotClass(conv);
|
|
760
|
+
const statusDot = document.createElement('span');
|
|
761
|
+
statusDot.className = 'state-dot conv-state-dot dot-' + dotClass;
|
|
762
|
+
statusDot.title = tfDotTitle(dotClass);
|
|
763
|
+
btn.appendChild(statusDot);
|
|
764
|
+
const statusSpan = document.createElement('span');
|
|
765
|
+
statusSpan.className = 'conv-status';
|
|
766
|
+
statusSpan.textContent = conversationStateLabel(conv);
|
|
767
|
+
statusSpan.classList.add(conversationUiState(conv));
|
|
768
|
+
btn.appendChild(statusSpan);
|
|
769
|
+
// Issue #442: A/B badge on rail entry.
|
|
770
|
+
if (conv.compareMode === 'ab') {
|
|
771
|
+
const badge = document.createElement('span');
|
|
772
|
+
badge.className = 'ab-badge';
|
|
773
|
+
badge.textContent = 'A/B';
|
|
774
|
+
btn.appendChild(badge);
|
|
775
|
+
}
|
|
776
|
+
tfAttachRunDelete(btn, conv, { tree: false }); // #533 R6: delete a run
|
|
777
|
+
btn.addEventListener('click', () => switchToConversation(conv.id));
|
|
778
|
+
groupList.appendChild(btn);
|
|
779
|
+
}
|
|
780
|
+
return groupList;
|
|
781
|
+
}
|
|
782
|
+
|
|
778
783
|
for (const group of groups.values()) {
|
|
779
784
|
const details = document.createElement('details');
|
|
780
785
|
details.className = 'conv-employee-group';
|
|
@@ -812,48 +817,46 @@ function renderRail() {
|
|
|
812
817
|
summary.appendChild(addBtn);
|
|
813
818
|
summary.appendChild(count);
|
|
814
819
|
details.appendChild(summary);
|
|
815
|
-
|
|
816
|
-
const groupList = document.createElement('div');
|
|
817
|
-
groupList.className = 'conv-employee-list';
|
|
818
|
-
|
|
819
|
-
for (const conv of group.conversations) {
|
|
820
|
-
const btn = document.createElement('button');
|
|
821
|
-
btn.type = 'button';
|
|
822
|
-
btn.className = 'conv-item' + (state.activeId === conv.id ? ' active' : '');
|
|
823
|
-
btn.dataset.conv = conv.id;
|
|
824
|
-
const bodyDiv = document.createElement('span');
|
|
825
|
-
bodyDiv.className = 'conv-body';
|
|
826
|
-
const titleSpan = document.createElement('span');
|
|
827
|
-
titleSpan.className = 'conv-title';
|
|
828
|
-
titleSpan.textContent = conv.title || '';
|
|
829
|
-
bodyDiv.appendChild(titleSpan);
|
|
830
|
-
btn.appendChild(bodyDiv);
|
|
831
|
-
const dotClass = conversationStateDotClass(conv);
|
|
832
|
-
const statusDot = document.createElement('span');
|
|
833
|
-
statusDot.className = 'state-dot conv-state-dot dot-' + dotClass;
|
|
834
|
-
statusDot.title = tfDotTitle(dotClass);
|
|
835
|
-
btn.appendChild(statusDot);
|
|
836
|
-
const statusSpan = document.createElement('span');
|
|
837
|
-
statusSpan.className = 'conv-status';
|
|
838
|
-
statusSpan.textContent = conversationStateLabel(conv);
|
|
839
|
-
statusSpan.classList.add(conversationUiState(conv));
|
|
840
|
-
btn.appendChild(statusSpan);
|
|
841
|
-
// Issue #442: A/B badge on rail entry.
|
|
842
|
-
if (conv.compareMode === 'ab') {
|
|
843
|
-
const badge = document.createElement('span');
|
|
844
|
-
badge.className = 'ab-badge';
|
|
845
|
-
badge.textContent = 'A/B';
|
|
846
|
-
btn.appendChild(badge);
|
|
847
|
-
}
|
|
848
|
-
tfAttachRunDelete(btn, conv, { tree: false }); // #533 R6: delete a run
|
|
849
|
-
btn.addEventListener('click', () => switchToConversation(conv.id));
|
|
850
|
-
groupList.appendChild(btn);
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
details.appendChild(groupList);
|
|
820
|
+
details.appendChild(buildGroupList(group.conversations));
|
|
854
821
|
els['conv-list'].appendChild(details);
|
|
855
822
|
}
|
|
856
823
|
|
|
824
|
+
// Issue #550 R2/R3: Render the Watercooler Conversations group after all
|
|
825
|
+
// employee groups, only when at least one unmatched ad-hoc conv exists.
|
|
826
|
+
// R9: the persona filter excludes unmatched ad-hoc convs entirely (they carry
|
|
827
|
+
// no personaKey so they never satisfy the filter), meaning watercoolerConvs is
|
|
828
|
+
// already empty when a persona filter is active.
|
|
829
|
+
if (watercoolerConvs.length > 0) {
|
|
830
|
+
const wcDetails = document.createElement('details');
|
|
831
|
+
wcDetails.className = 'conv-employee-group conv-employee-group--adhoc';
|
|
832
|
+
wcDetails.open = true;
|
|
833
|
+
const wcSummary = document.createElement('summary');
|
|
834
|
+
wcSummary.className = 'conv-employee-tab';
|
|
835
|
+
// R4: dashed-border avatar placeholder — no image or initials.
|
|
836
|
+
const wcAvatar = document.createElement('span');
|
|
837
|
+
wcAvatar.className = 'conv-employee-avatar conv-employee-avatar--adhoc';
|
|
838
|
+
const wcCopy = document.createElement('span');
|
|
839
|
+
wcCopy.className = 'conv-employee-tab-copy';
|
|
840
|
+
const wcLabel = document.createElement('strong');
|
|
841
|
+
wcLabel.className = 'conv-employee-tab-label';
|
|
842
|
+
wcLabel.textContent = 'Watercooler Conversations'; // R5
|
|
843
|
+
const wcDetail = document.createElement('small');
|
|
844
|
+
wcDetail.className = 'conv-employee-tab-detail';
|
|
845
|
+
wcDetail.textContent = 'Unmatched ad-hoc tasks'; // R5
|
|
846
|
+
wcCopy.appendChild(wcLabel);
|
|
847
|
+
wcCopy.appendChild(wcDetail);
|
|
848
|
+
const wcCount = document.createElement('span');
|
|
849
|
+
wcCount.className = 'conv-employee-tab-count';
|
|
850
|
+
wcCount.textContent = String(watercoolerConvs.length);
|
|
851
|
+
// R6: NO "+ assign" button in Watercooler summary.
|
|
852
|
+
wcSummary.appendChild(wcAvatar);
|
|
853
|
+
wcSummary.appendChild(wcCopy);
|
|
854
|
+
wcSummary.appendChild(wcCount);
|
|
855
|
+
wcDetails.appendChild(wcSummary);
|
|
856
|
+
wcDetails.appendChild(buildGroupList(watercoolerConvs));
|
|
857
|
+
els['conv-list'].appendChild(wcDetails);
|
|
858
|
+
}
|
|
859
|
+
|
|
857
860
|
// #521: hide the "Runs" section header when there's nothing in it — in the
|
|
858
861
|
// workspace, project-update jobs are deduped out, so a project that has only
|
|
859
862
|
// run onboarding would otherwise show a dangling empty "Runs" label.
|
|
@@ -870,6 +873,11 @@ function statusLabel(s) {
|
|
|
870
873
|
function conversationUiState(conv) {
|
|
871
874
|
if (!conv) return 'idle';
|
|
872
875
|
if (conv.status === 'running') return 'working';
|
|
876
|
+
// #549: conv.stopped is set by tfStopRun after a manager-initiated stop.
|
|
877
|
+
// A stopped run is an intentional pause — visually distinct from an error-failed run.
|
|
878
|
+
// Guard on status==='failed' only: a completed run should resolve to complete/waiting,
|
|
879
|
+
// not stay stuck as stopped even if the stopped flag was not cleared.
|
|
880
|
+
if (conv.stopped && conv.status === 'failed') return 'stopped';
|
|
873
881
|
if (conv.status === 'failed') return 'waiting';
|
|
874
882
|
if (conv.status === 'completed' && conv.reviewApproved) return 'complete';
|
|
875
883
|
if (conv.status === 'completed') return 'waiting';
|
|
@@ -881,6 +889,8 @@ function conversationStateDotClass(conv) {
|
|
|
881
889
|
if (uiState === 'working') return 'amber';
|
|
882
890
|
if (uiState === 'waiting') return 'red';
|
|
883
891
|
if (uiState === 'complete') return 'green';
|
|
892
|
+
// #549: stopped runs use a non-pulsing amber dot (intentional pause, not error).
|
|
893
|
+
if (uiState === 'stopped') return 'amber-static';
|
|
884
894
|
return 'grey';
|
|
885
895
|
}
|
|
886
896
|
|
|
@@ -889,6 +899,8 @@ function conversationStateLabel(conv) {
|
|
|
889
899
|
if (uiState === 'working') return 'Working';
|
|
890
900
|
if (uiState === 'waiting') return 'Waiting on you';
|
|
891
901
|
if (uiState === 'complete') return 'Done';
|
|
902
|
+
// #549: manager-stopped run gets a distinct "Stopped" label in the rail.
|
|
903
|
+
if (uiState === 'stopped') return 'Stopped';
|
|
892
904
|
return 'Idle';
|
|
893
905
|
}
|
|
894
906
|
|
|
@@ -1301,6 +1313,7 @@ function renderActive() {
|
|
|
1301
1313
|
}
|
|
1302
1314
|
els['empty'].hidden = true;
|
|
1303
1315
|
els['active-conv'].hidden = false;
|
|
1316
|
+
els['active-conv'].dataset.runId = conv.runId || '';
|
|
1304
1317
|
els['active-title'].textContent = conversationTitle(conv);
|
|
1305
1318
|
renderConversationIdentity(conv);
|
|
1306
1319
|
renderRunStatePill(conv);
|
|
@@ -1516,6 +1529,8 @@ function renderRunStatePill(conv) {
|
|
|
1516
1529
|
pill.textContent = 'DONE';
|
|
1517
1530
|
pill.className = 'run-state-pill complete';
|
|
1518
1531
|
} else {
|
|
1532
|
+
// #549 R3: conversationUiState now returns 'stopped' for manager-stopped runs.
|
|
1533
|
+
// conversationStateLabel returns 'Stopped' for that state; toUpperCase() => 'STOPPED'.
|
|
1519
1534
|
pill.textContent = conversationStateLabel(conv).toUpperCase();
|
|
1520
1535
|
pill.className = `run-state-pill ${conversationUiState(conv)}`;
|
|
1521
1536
|
}
|
|
@@ -1523,16 +1538,33 @@ function renderRunStatePill(conv) {
|
|
|
1523
1538
|
const stopBtn = els['run-stop-btn'];
|
|
1524
1539
|
if (stopBtn) {
|
|
1525
1540
|
const canStop = conv.status === 'running' && !!conv.runId && !conv._stopping;
|
|
1526
|
-
|
|
1541
|
+
// #549 R1/AC1.3: While in-flight (_stopping===true), keep the button VISIBLE but disabled
|
|
1542
|
+
// so the manager retains the affordance showing "Stopping…". Hide only when the run is
|
|
1543
|
+
// not active at all (canStop===false AND not in-flight).
|
|
1544
|
+
stopBtn.hidden = !canStop && !conv._stopping;
|
|
1527
1545
|
stopBtn.disabled = !!conv._stopping;
|
|
1546
|
+
if (conv._stopping) {
|
|
1547
|
+
stopBtn.textContent = '⏹ Stopping…';
|
|
1548
|
+
stopBtn.setAttribute('aria-label', 'Stopping the employee, please wait');
|
|
1549
|
+
} else {
|
|
1550
|
+
stopBtn.textContent = '⏹ Stop';
|
|
1551
|
+
stopBtn.setAttribute('aria-label', 'Ask the employee to stop and wait for you');
|
|
1552
|
+
}
|
|
1528
1553
|
}
|
|
1529
1554
|
}
|
|
1530
1555
|
|
|
1531
1556
|
// #521: ask the agent to stop and park the run in "waiting on you" mode.
|
|
1532
1557
|
async function tfStopRun(conv) {
|
|
1533
|
-
|
|
1558
|
+
// #549 B2: Guard against _stopping so a rapid double-click (or force-click
|
|
1559
|
+
// that bypasses the disabled button) cannot fire a second stop request while
|
|
1560
|
+
// the first is still in-flight. The button is set disabled in renderRunStatePill
|
|
1561
|
+
// but DOM state is not a reliable guard for programmatic re-entry.
|
|
1562
|
+
if (!conv || !conv.runId || conv.status !== 'running' || conv._stopping) return;
|
|
1534
1563
|
conv._stopping = true;
|
|
1535
1564
|
if (typeof renderRunStatePill === 'function') renderRunStatePill(conv);
|
|
1565
|
+
// #549 R1: Update the thread indicator immediately to show the in-flight dash
|
|
1566
|
+
// so the manager sees "stopping in progress" rather than "still working".
|
|
1567
|
+
if (typeof syncWorkingIndicator === 'function') syncWorkingIndicator(conv);
|
|
1536
1568
|
try {
|
|
1537
1569
|
const run = await requestJson('/api/ai-hub/runs/' + encodeURIComponent(conv.runId) + '/stop', {
|
|
1538
1570
|
method: 'POST',
|
|
@@ -1553,6 +1585,11 @@ async function tfStopRun(conv) {
|
|
|
1553
1585
|
} catch (e) {
|
|
1554
1586
|
conv._stopping = false;
|
|
1555
1587
|
if (typeof renderRunStatePill === 'function') renderRunStatePill(conv);
|
|
1588
|
+
// #549 B1: Restore the three typing-dots when the stop POST fails.
|
|
1589
|
+
// Without this call, syncWorkingIndicator was never called after the catch
|
|
1590
|
+
// cleared _stopping, leaving the stopping-dash in #employee-working-indicator
|
|
1591
|
+
// permanently even though the employee is still running.
|
|
1592
|
+
if (typeof syncWorkingIndicator === 'function') syncWorkingIndicator(conv);
|
|
1556
1593
|
if (typeof showStatus === 'function') showStatus((e && e.message) || 'Could not stop the run.', true);
|
|
1557
1594
|
}
|
|
1558
1595
|
}
|
|
@@ -1571,23 +1608,58 @@ function syncThreadUiState(conv) {
|
|
|
1571
1608
|
function syncWorkingIndicator(conv) {
|
|
1572
1609
|
const host = els['messages'];
|
|
1573
1610
|
if (!host) return;
|
|
1574
|
-
const
|
|
1611
|
+
const uiState = conversationUiState(conv);
|
|
1612
|
+
const isStopping = conv && conv._stopping;
|
|
1613
|
+
const shouldShowWorking = uiState === 'working';
|
|
1614
|
+
const shouldShowStopped = uiState === 'stopped';
|
|
1615
|
+
|
|
1616
|
+
// ── #549 R2: Remove stopped indicator when state is no longer stopped. ──
|
|
1617
|
+
let stoppedIndicator = host.querySelector('#employee-stopped-indicator');
|
|
1618
|
+
if (!shouldShowStopped && stoppedIndicator) {
|
|
1619
|
+
stoppedIndicator.remove();
|
|
1620
|
+
stoppedIndicator = null;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
// ── Working indicator (three-dot bounce or in-flight dash). ──
|
|
1575
1624
|
let indicator = host.querySelector('#employee-working-indicator');
|
|
1576
|
-
if (!
|
|
1625
|
+
if (!shouldShowWorking && !isStopping) {
|
|
1577
1626
|
if (indicator) indicator.remove();
|
|
1578
|
-
|
|
1627
|
+
} else {
|
|
1628
|
+
// Create the bubble if it doesn't exist yet.
|
|
1629
|
+
if (!indicator) {
|
|
1630
|
+
indicator = document.createElement('article');
|
|
1631
|
+
indicator.id = 'employee-working-indicator';
|
|
1632
|
+
indicator.className = 'message typing-indicator';
|
|
1633
|
+
indicator.setAttribute('aria-label', 'Employee is working');
|
|
1634
|
+
const bubble = document.createElement('div');
|
|
1635
|
+
bubble.className = 'bubble';
|
|
1636
|
+
bubble.innerHTML = '<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>';
|
|
1637
|
+
indicator.appendChild(bubble);
|
|
1638
|
+
}
|
|
1639
|
+
// #549 R1: While stop is in-flight, replace the three-dot animation with
|
|
1640
|
+
// a single static dash so the manager sees "stopping" rather than "still working".
|
|
1641
|
+
const bubble = indicator.querySelector('.bubble');
|
|
1642
|
+
if (isStopping) {
|
|
1643
|
+
bubble.innerHTML = '<span class="stopping-dash">–</span>';
|
|
1644
|
+
} else if (!bubble.querySelector('.typing-dot')) {
|
|
1645
|
+
// Restore three dots if _stopping cleared (e.g. stop failed).
|
|
1646
|
+
bubble.innerHTML = '<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>';
|
|
1647
|
+
}
|
|
1648
|
+
host.appendChild(indicator);
|
|
1579
1649
|
}
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1650
|
+
|
|
1651
|
+
// ── #549 R2: Show stopped-state system message after stop completes. ──
|
|
1652
|
+
if (shouldShowStopped && !stoppedIndicator) {
|
|
1653
|
+
const article = document.createElement('article');
|
|
1654
|
+
article.id = 'employee-stopped-indicator';
|
|
1655
|
+
article.className = 'message system';
|
|
1656
|
+
article.setAttribute('aria-label', 'Employee stopped');
|
|
1585
1657
|
const bubble = document.createElement('div');
|
|
1586
1658
|
bubble.className = 'bubble';
|
|
1587
|
-
bubble.
|
|
1588
|
-
|
|
1659
|
+
bubble.textContent = '⏸ Employee stopped. Send your next instruction to continue.';
|
|
1660
|
+
article.appendChild(bubble);
|
|
1661
|
+
host.appendChild(article);
|
|
1589
1662
|
}
|
|
1590
|
-
host.appendChild(indicator);
|
|
1591
1663
|
}
|
|
1592
1664
|
|
|
1593
1665
|
function buildConversationSummary(conv) {
|
|
@@ -2020,7 +2092,7 @@ function stripStubReference(text) {
|
|
|
2020
2092
|
}
|
|
2021
2093
|
|
|
2022
2094
|
function surfaceText(role, text, conv) {
|
|
2023
|
-
const raw = stripStubReference(text);
|
|
2095
|
+
const raw = stripHubInjectedPromptBlocks(stripStubReference(text));
|
|
2024
2096
|
if (!raw) return '';
|
|
2025
2097
|
|
|
2026
2098
|
if (role === 'manager') {
|
|
@@ -2143,20 +2215,13 @@ function ensureThreadMessageViewportObserver() {
|
|
|
2143
2215
|
}
|
|
2144
2216
|
|
|
2145
2217
|
function syncThreadMessageViewport() {
|
|
2146
|
-
const panel = els['thread-panel'];
|
|
2147
2218
|
const messages = els['messages'];
|
|
2148
|
-
if (!
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
messages.style.height = `${Math.max(120, available)}px`;
|
|
2155
|
-
messages.style.maxHeight = `${Math.max(120, available)}px`;
|
|
2156
|
-
} else {
|
|
2157
|
-
messages.style.height = '';
|
|
2158
|
-
messages.style.maxHeight = '';
|
|
2159
|
-
}
|
|
2219
|
+
if (!messages) return;
|
|
2220
|
+
// Flex layout now owns the thread height: #thread-panel[open] grows to fill the
|
|
2221
|
+
// available real estate and #messages (flex child, overflow-y:auto) scrolls
|
|
2222
|
+
// internally. Clear any legacy inline sizing so CSS stays authoritative.
|
|
2223
|
+
messages.style.height = '';
|
|
2224
|
+
messages.style.maxHeight = '';
|
|
2160
2225
|
}
|
|
2161
2226
|
|
|
2162
2227
|
function scrollThreadAfterViewportSync(conv, runningShouldStickToBottom, shouldScrollForUpdate) {
|
|
@@ -2185,7 +2250,8 @@ function scrollThreadForReview(conv) {
|
|
|
2185
2250
|
return;
|
|
2186
2251
|
}
|
|
2187
2252
|
|
|
2188
|
-
const
|
|
2253
|
+
const completion = host.querySelector('#review-completion');
|
|
2254
|
+
const target = completion || [...nodes].reverse().find((node) =>
|
|
2189
2255
|
node.classList.contains('employee') || node.classList.contains('system')
|
|
2190
2256
|
) || nodes[nodes.length - 1];
|
|
2191
2257
|
|
|
@@ -2193,7 +2259,7 @@ function scrollThreadForReview(conv) {
|
|
|
2193
2259
|
const targetRect = target.getBoundingClientRect();
|
|
2194
2260
|
const currentTop = host.scrollTop;
|
|
2195
2261
|
const targetTopInsideHost = currentTop + (targetRect.top - hostRect.top);
|
|
2196
|
-
const reviewOffset = Math.max(24, host.clientHeight * 0.16);
|
|
2262
|
+
const reviewOffset = completion ? 8 : Math.max(24, host.clientHeight * 0.16);
|
|
2197
2263
|
const desiredTop = Math.max(0, targetTopInsideHost - reviewOffset);
|
|
2198
2264
|
host.scrollTo({ top: desiredTop, behavior: 'smooth' });
|
|
2199
2265
|
}
|
|
@@ -2449,6 +2515,25 @@ function artifactLabel(artifact) {
|
|
|
2449
2515
|
return artifact.label || artifact.name || artifact.path || artifact.url || 'artifact';
|
|
2450
2516
|
}
|
|
2451
2517
|
|
|
2518
|
+
function artifactBasename(artifact) {
|
|
2519
|
+
const raw = artifact && (artifact.path || artifact.url || artifact.name || '');
|
|
2520
|
+
const cleaned = String(raw || '').split(/[?#]/)[0];
|
|
2521
|
+
return cleaned.split(/[\\/]/).filter(Boolean).pop() || '';
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
function artifactDisplayLabel(artifact) {
|
|
2525
|
+
const label = artifactLabel(artifact);
|
|
2526
|
+
const base = artifactBasename(artifact);
|
|
2527
|
+
if (!base || label === base || String(label).includes(base)) return label;
|
|
2528
|
+
return `${label} (${base})`;
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
function reviewArtifactSummary(handoff) {
|
|
2532
|
+
const artifacts = handoff && Array.isArray(handoff.artifacts) ? handoff.artifacts : [];
|
|
2533
|
+
if (!artifacts.length) return '';
|
|
2534
|
+
return artifacts.map(artifactDisplayLabel).join('; ');
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2452
2537
|
function artifactLocalPath(artifact) {
|
|
2453
2538
|
if (!artifact) return '';
|
|
2454
2539
|
const raw = artifact.path
|
|
@@ -2500,7 +2585,7 @@ function deriveFormatFromReviewHandoff(handoff) {
|
|
|
2500
2585
|
const actions = artifacts.length > 0
|
|
2501
2586
|
? artifacts.map((artifact, index) => ({
|
|
2502
2587
|
id: `review-artifact-${index}`,
|
|
2503
|
-
label:
|
|
2588
|
+
label: artifactDisplayLabel(artifact),
|
|
2504
2589
|
primary: index === 0,
|
|
2505
2590
|
kind: artifactActionKind(artifact),
|
|
2506
2591
|
artifact,
|
|
@@ -2564,7 +2649,7 @@ function deriveDeliverableFormat(conv) {
|
|
|
2564
2649
|
key: 'markdown',
|
|
2565
2650
|
label: artifactName || artifactLabel(artifact),
|
|
2566
2651
|
actions: [
|
|
2567
|
-
{ id: '
|
|
2652
|
+
{ id: 'open-artifact', label: artifactDisplayLabel(artifact), primary: true, kind: 'doc', artifact },
|
|
2568
2653
|
{ id: 'inline-feedback', label: 'Type feedback inline', primary: false, kind: 'feedback', artifact },
|
|
2569
2654
|
],
|
|
2570
2655
|
};
|
|
@@ -2585,7 +2670,7 @@ function deriveDeliverableFormat(conv) {
|
|
|
2585
2670
|
// read-side already gives us and degrade gracefully when a field is unknown.
|
|
2586
2671
|
function stripMarkdownForDisplay(text) {
|
|
2587
2672
|
if (!text) return text;
|
|
2588
|
-
return stripReviewHandoffBlocks(text)
|
|
2673
|
+
return stripHubInjectedPromptBlocks(stripReviewHandoffBlocks(text))
|
|
2589
2674
|
.replace(/\*\*(.+?)\*\*/g, '$1')
|
|
2590
2675
|
.replace(/\*(.+?)\*/g, '$1')
|
|
2591
2676
|
.replace(/^---+$/gm, '')
|
|
@@ -2614,8 +2699,10 @@ function buildReviewCardRows(conv, fmt) {
|
|
|
2614
2699
|
: `Completed "${conv.title || conv.jobTitle || 'the assigned work'}".`;
|
|
2615
2700
|
return [
|
|
2616
2701
|
{ k: 'What changed', v: whatChanged },
|
|
2617
|
-
|
|
2618
|
-
? { k: 'Files updated', v: `${fmt.label}` }
|
|
2702
|
+
handoff && handoff.reviewTarget && handoff.reviewTarget.type === 'artifact_set'
|
|
2703
|
+
? { k: 'Files updated', v: reviewArtifactSummary(handoff) || `${fmt.label}` }
|
|
2704
|
+
: reliableLabel
|
|
2705
|
+
? { k: 'Files updated', v: `${fmt.label}` }
|
|
2619
2706
|
: { k: 'Where to look', v: 'Open the updated files in your project (the Brief section shows the project context & rules) to review.' },
|
|
2620
2707
|
{ k: 'What to do next', v: 'Approve, or type what to change in the Coach box below.' },
|
|
2621
2708
|
];
|
|
@@ -2623,15 +2710,27 @@ function buildReviewCardRows(conv, fmt) {
|
|
|
2623
2710
|
const whatIDid = lastEmployee
|
|
2624
2711
|
? clampSummaryText(stripMarkdownForDisplay(lastEmployee), 220)
|
|
2625
2712
|
: `Completed “${conv.title || conv.jobTitle || 'the assigned work'}”.`;
|
|
2713
|
+
if (handoff && handoff.reviewTarget && handoff.reviewTarget.type === 'artifact_set') {
|
|
2714
|
+
return [
|
|
2715
|
+
{ k: 'Files to review', v: reviewArtifactSummary(handoff) || `${fmt.label}` },
|
|
2716
|
+
{ k: 'What I need', v: 'Your review — approve, or tell me what to sharpen.' },
|
|
2717
|
+
];
|
|
2718
|
+
}
|
|
2626
2719
|
const rows = [{ k: 'What I did', v: whatIDid }];
|
|
2627
|
-
if (reliableLabel)
|
|
2720
|
+
if (reliableLabel) {
|
|
2721
|
+
rows.push({ k: 'Deliverable', v: `${fmt.label}` });
|
|
2722
|
+
}
|
|
2628
2723
|
rows.push({ k: 'What I need', v: 'Your review — approve, or tell me what to sharpen.' });
|
|
2629
2724
|
return rows;
|
|
2630
2725
|
}
|
|
2631
2726
|
|
|
2632
|
-
// The completion card
|
|
2633
|
-
//
|
|
2634
|
-
//
|
|
2727
|
+
// The completion card is a decision surface, not chat history. Keep it outside
|
|
2728
|
+
// the collapsible thread so the exact deliverables remain visible even when the
|
|
2729
|
+
// manager minimizes the conversation transcript.
|
|
2730
|
+
// The "Ready for your review" card is the employee's review turn, so it lives as
|
|
2731
|
+
// the LAST child of #messages — inside the manager/employee thread. Keeping it in
|
|
2732
|
+
// the thread (rather than as a separate panel below it) means it scrolls with the
|
|
2733
|
+
// conversation and the thread does not need to collapse to surface it.
|
|
2635
2734
|
function reviewCompletionHost() {
|
|
2636
2735
|
const messages = els['messages'];
|
|
2637
2736
|
if (!messages) return null;
|
|
@@ -2649,6 +2748,12 @@ function clearReviewCompletion() {
|
|
|
2649
2748
|
const messages = els['messages'];
|
|
2650
2749
|
const existing = messages && messages.querySelector('#review-completion');
|
|
2651
2750
|
if (existing) existing.remove();
|
|
2751
|
+
if (messages) {
|
|
2752
|
+
messages.classList.remove('review-focused');
|
|
2753
|
+
messages.querySelectorAll('.review-source-message').forEach((node) => {
|
|
2754
|
+
node.classList.remove('review-source-message');
|
|
2755
|
+
});
|
|
2756
|
+
}
|
|
2652
2757
|
}
|
|
2653
2758
|
|
|
2654
2759
|
function renderReviewExperience(conv) {
|
|
@@ -2728,9 +2833,9 @@ function renderReviewExperience(conv) {
|
|
|
2728
2833
|
}
|
|
2729
2834
|
}
|
|
2730
2835
|
|
|
2731
|
-
// Comments follow the artifact (R7.7): a PR opens GitHub;
|
|
2732
|
-
//
|
|
2733
|
-
// read-side already knows and otherwise leave a status hint.
|
|
2836
|
+
// Comments follow the artifact (R7.7): a PR opens GitHub; local artifacts open
|
|
2837
|
+
// from disk; reports view inline. Nothing new is invented - these surface what
|
|
2838
|
+
// the read-side already knows and otherwise leave a status hint.
|
|
2734
2839
|
async function handleArtifactAction(conv, action) {
|
|
2735
2840
|
const artifact = action.artifact || ((conv && conv.artifacts && conv.artifacts[0]) || null);
|
|
2736
2841
|
if (action.kind === 'github') {
|
|
@@ -2774,55 +2879,6 @@ async function handleArtifactAction(conv, action) {
|
|
|
2774
2879
|
}
|
|
2775
2880
|
return;
|
|
2776
2881
|
}
|
|
2777
|
-
if (action.kind === 'export') {
|
|
2778
|
-
// Download the deliverable as .docx for Word annotation. Try the on-disk file
|
|
2779
|
-
// first; if it doesn't actually exist (404 — e.g. the employee named a file it
|
|
2780
|
-
// never created) or there is no path, fall back to exporting the employee's
|
|
2781
|
-
// written deliverable text. Either way we download a real .docx, never a JSON
|
|
2782
|
-
// error. Feedback comes back through the Coach box (no "Done reviewing").
|
|
2783
|
-
const artifactPath = artifact && (artifact.path || artifact.where || artifact.url);
|
|
2784
|
-
const hasLocalFile = artifactPath && !String(artifactPath).startsWith('http');
|
|
2785
|
-
const baseName = ((artifact && artifact.name) || conversationTitle(conv) || 'deliverable');
|
|
2786
|
-
const triggerDownload = (blob) => {
|
|
2787
|
-
const url = URL.createObjectURL(blob);
|
|
2788
|
-
const a = document.createElement('a');
|
|
2789
|
-
a.href = url;
|
|
2790
|
-
a.download = String(baseName).replace(/\.[^.]+$/, '') + '.docx';
|
|
2791
|
-
a.style.display = 'none';
|
|
2792
|
-
document.body.appendChild(a);
|
|
2793
|
-
a.click();
|
|
2794
|
-
document.body.removeChild(a);
|
|
2795
|
-
URL.revokeObjectURL(url);
|
|
2796
|
-
};
|
|
2797
|
-
const isDocx = (resp) => (resp.headers.get('content-type') || '').includes('word');
|
|
2798
|
-
try {
|
|
2799
|
-
let blob = null;
|
|
2800
|
-
if (hasLocalFile) {
|
|
2801
|
-
const resp = await fetch('/api/ai-hub/artifact/export-docx?path=' + encodeURIComponent(artifactPath));
|
|
2802
|
-
if (resp.ok && isDocx(resp)) blob = await resp.blob();
|
|
2803
|
-
// else: file missing on disk → fall through to the inline export below.
|
|
2804
|
-
}
|
|
2805
|
-
if (!blob) {
|
|
2806
|
-
const inline = (typeof latestEmployeeSurfaceText === 'function' && latestEmployeeSurfaceText(conv)) || '';
|
|
2807
|
-
if (!inline.trim()) {
|
|
2808
|
-
showStatus('Nothing to download yet — the employee has not produced a file or a written deliverable.', true);
|
|
2809
|
-
return;
|
|
2810
|
-
}
|
|
2811
|
-
const resp = await fetch('/api/ai-hub/artifact/export-docx', {
|
|
2812
|
-
method: 'POST',
|
|
2813
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2814
|
-
body: JSON.stringify({ content: inline, filename: baseName }),
|
|
2815
|
-
});
|
|
2816
|
-
if (!resp.ok) { const e = await resp.json().catch(() => ({})); throw new Error(e.error || 'Export failed.'); }
|
|
2817
|
-
blob = await resp.blob();
|
|
2818
|
-
}
|
|
2819
|
-
triggerDownload(blob);
|
|
2820
|
-
showStatus('Downloaded as .docx — mark it up in Word, then type your feedback in the Coach box below to send changes back.', false);
|
|
2821
|
-
} catch (err) {
|
|
2822
|
-
showStatus(err instanceof Error ? err.message : 'Could not export the deliverable.', true);
|
|
2823
|
-
}
|
|
2824
|
-
return;
|
|
2825
|
-
}
|
|
2826
2882
|
// Any other action: feedback goes through the Coach box.
|
|
2827
2883
|
showStatus('Type your feedback in the Coach box below — it becomes a coaching message for the employee.', false);
|
|
2828
2884
|
}
|
|
@@ -3837,6 +3893,9 @@ async function continueRun(text) {
|
|
|
3837
3893
|
const coachingJobId = state.pendingCoachingJobId || undefined;
|
|
3838
3894
|
clearPendingCoachingJob();
|
|
3839
3895
|
conv.reviewApproved = false;
|
|
3896
|
+
// #549 R6/AC6.1: Clear the stopped flag before starting the new run so
|
|
3897
|
+
// stopped-state indicators are removed on the next render tick.
|
|
3898
|
+
conv.stopped = false;
|
|
3840
3899
|
conv.status = 'running';
|
|
3841
3900
|
upsertConversation(conv);
|
|
3842
3901
|
refreshStatusSurfaces(); // #533 R5: also recolor the tree/area dots back to working
|
|
@@ -4651,7 +4710,8 @@ function wireEvents() {
|
|
|
4651
4710
|
// This is a belt-and-suspenders fallback in case the inline script was skipped.
|
|
4652
4711
|
|
|
4653
4712
|
gatherElements();
|
|
4654
|
-
|
|
4713
|
+
state.conversations = {};
|
|
4714
|
+
state.activeId = null;
|
|
4655
4715
|
wirePopovers();
|
|
4656
4716
|
wireEvents();
|
|
4657
4717
|
|
|
@@ -4707,7 +4767,6 @@ function wireEvents() {
|
|
|
4707
4767
|
if (convNeedsPolling(conv)) startPolling();
|
|
4708
4768
|
} else {
|
|
4709
4769
|
state.activeId = null;
|
|
4710
|
-
persistConversations();
|
|
4711
4770
|
renderActive();
|
|
4712
4771
|
}
|
|
4713
4772
|
})();
|