fraim-framework 2.0.152 → 2.0.153

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.
@@ -21,9 +21,10 @@ const state = {
21
21
  selectedJob: null, // chosen in modal step 1
22
22
  selectedEmployeeId: null,
23
23
  selectedPersonaKey: null, // R4: null = "All employees"
24
- modalPersonaFilter: null, // R3+: filter inside new-job modal; null = "All jobs"
25
- storedApiKey: null, // R2: loaded from preferences, sent on bootstrap
26
- };
24
+ modalPersonaFilter: null, // R3+: filter inside new-job modal; null = "All jobs"
25
+ storedApiKey: null, // R2: loaded from preferences, sent on bootstrap
26
+ panelState: {}, // { [convId]: { coach?: boolean } }
27
+ };
27
28
 
28
29
  const els = {};
29
30
 
@@ -37,11 +38,12 @@ function gatherElements() {
37
38
  'new-conv-btn', 'conv-list',
38
39
  // Issue #385: team roster
39
40
  'team-roster',
40
- 'empty', 'active-conv', 'active-title', 'active-job', 'active-identity', 'run-state-pill', 'summary-strip',
41
+ 'empty', 'active-conv', 'active-title', 'active-job', 'active-identity', 'run-state-pill',
41
42
  'progress', 'stage', 'latest', 'artifact-slot', 'messages',
42
43
  'coach-text', 'send', 'micro-manage', 'micro-log',
43
44
  'status-line', 'coach-note',
44
- 'modal', 'step1', 'step2',
45
+ 'coach-panel', 'coach-summary',
46
+ 'modal', 'step1', 'step2',
45
47
  'cancel1', 'next1', 'back2', 'start',
46
48
  'job-search', 'job-catalog', 'job-pick-status',
47
49
  // Issue #385: hire-required notice, persona job filter
@@ -224,10 +226,34 @@ function findConversation(id) {
224
226
  return null;
225
227
  }
226
228
 
227
- function activeConversation() {
228
- if (!state.activeId) return null;
229
- return findConversation(state.activeId);
230
- }
229
+ function activeConversation() {
230
+ if (!state.activeId) return null;
231
+ return findConversation(state.activeId);
232
+ }
233
+
234
+ function panelStateFor(convId) {
235
+ if (!convId) return {};
236
+ if (!state.panelState[convId]) state.panelState[convId] = {};
237
+ return state.panelState[convId];
238
+ }
239
+
240
+ function defaultCoachOpen(conv) {
241
+ return true;
242
+ }
243
+
244
+ function syncConversationPanels(conv, switchedConv) {
245
+ const coach = els['coach-panel'];
246
+ if (!conv || !coach) return;
247
+ const panelState = panelStateFor(conv.id);
248
+ if (switchedConv) {
249
+ coach.open = panelState.coach ?? defaultCoachOpen(conv);
250
+ }
251
+ if (els['coach-summary']) {
252
+ els['coach-summary'].textContent = conv.status === 'running'
253
+ ? 'Open to coach or redirect the work.'
254
+ : 'Open when you want to steer the next step.';
255
+ }
256
+ }
231
257
 
232
258
  function upsertConversation(conv) {
233
259
  const list = projectConversations().slice();
@@ -565,7 +591,6 @@ function renderActive() {
565
591
  renderConversationIdentity(conv);
566
592
  els['active-job'].textContent = `${conv.jobTitle} · ${friendlyProjectName(conv.projectPath || state.projectPath)}`;
567
593
  renderRunStatePill(conv);
568
- els['summary-strip'].textContent = buildConversationSummary(conv);
569
594
  els['coach-note'].textContent = conv.status === 'running'
570
595
  ? 'The employee is still working. Add coaching here to tighten the next step without losing context.'
571
596
  : 'The employee is waiting on you. Send the next instruction to continue this run.';
@@ -579,7 +604,7 @@ function renderActive() {
579
604
 
580
605
  // If we switched conversations (or this is the first render), wipe and
581
606
  // start fresh. Otherwise we're going to do an incremental update below.
582
- const switchedConv = renderedConvId !== conv.id;
607
+ const switchedConv = renderedConvId !== conv.id;
583
608
  if (switchedConv) {
584
609
  els['artifact-slot'].innerHTML = '';
585
610
  els['messages'].innerHTML = '';
@@ -590,6 +615,7 @@ function renderActive() {
590
615
  renderedArtifactKey = null;
591
616
  }
592
617
  const statusChanged = renderedStatus !== conv.status;
618
+ syncConversationPanels(conv, switchedConv);
593
619
 
594
620
  // Artifact callout — only re-render when the latest artifact actually
595
621
  // changed. Avoids the 'pulse' animation re-firing on every poll tick.
@@ -1025,21 +1051,23 @@ function closeTemplatePopover() {
1025
1051
  btn.setAttribute('aria-expanded', 'false');
1026
1052
  }
1027
1053
 
1028
- function applyTemplateInvocation(managerJobId) {
1029
- const conv = activeConversation();
1054
+ function applyTemplateInvocation(managerJobId) {
1055
+ const conv = activeConversation();
1030
1056
  // Use the conversation's own employee for the invocation symbol, NOT
1031
1057
  // the manager's last selection in another conversation (R2.5).
1032
1058
  const employeeId = (conv && conv.employeeId) || state.selectedEmployeeId || 'claude';
1033
1059
  const symbol = FRAIM_INVOCATION_SYMBOL[employeeId] || '/fraim';
1034
- const invocation = `${symbol} ${managerJobId}`;
1035
- const textarea = els['coach-text'];
1036
- const prior = textarea.value;
1037
- // Append (with a single space separator if needed) — never replace.
1038
- let combined;
1039
- if (prior.length === 0) combined = invocation;
1040
- else if (/\s$/.test(prior)) combined = prior + invocation;
1041
- else combined = prior + ' ' + invocation;
1042
- textarea.value = combined;
1060
+ const invocation = `${symbol} ${managerJobId}`;
1061
+ const textarea = els['coach-text'];
1062
+ const prior = textarea.value;
1063
+ let combined;
1064
+ if (prior.trim().length === 0) {
1065
+ combined = invocation;
1066
+ } else {
1067
+ const strippedPrior = prior.replace(/(?:^|\n|\s)[/$]fraim\s+[a-z0-9-]+(?:\s|$)/ig, ' ').replace(/\s+/g, ' ').trim();
1068
+ combined = strippedPrior ? `${invocation}\n\n${strippedPrior}` : invocation;
1069
+ }
1070
+ textarea.value = combined;
1043
1071
  // Caret at the end.
1044
1072
  textarea.setSelectionRange(combined.length, combined.length);
1045
1073
  textarea.focus();
@@ -1100,9 +1128,32 @@ function surfaceText(role, text, conv) {
1100
1128
  }
1101
1129
  }
1102
1130
 
1131
+ if (role === 'employee') {
1132
+ const resumedMatch = raw.match(/^Resumed\s+\w+\s+session\s+[a-f0-9-]+:\s*(.*)$/i);
1133
+ if (resumedMatch) {
1134
+ const cleaned = surfaceText('manager', resumedMatch[1], conv);
1135
+ if (conv.status === 'completed') return 'Done - please review.';
1136
+ return cleaned ? `Working on: ${cleaned}` : 'Working on it...';
1137
+ }
1138
+ }
1139
+
1103
1140
  return raw;
1104
1141
  }
1105
1142
 
1143
+ function extractExplicitFraimInvocation(text) {
1144
+ const raw = String(text || '');
1145
+ const match = raw.match(/(?:^|\n|\s)([$/]fraim)\s+([a-z0-9][a-z0-9-]*)(?=\s|$)/i);
1146
+ if (!match || match.index == null) return null;
1147
+ const before = raw.slice(0, match.index).trim();
1148
+ const after = raw.slice(match.index + match[0].length).trim();
1149
+ const remainder = [before, after].filter(Boolean).join('\n\n').trim();
1150
+ return {
1151
+ symbol: match[1],
1152
+ jobId: match[2],
1153
+ remainder,
1154
+ };
1155
+ }
1156
+
1106
1157
  function latestEmployeeSurfaceText(conv) {
1107
1158
  const messages = conv.messages || [];
1108
1159
  for (let i = messages.length - 1; i >= 0; i -= 1) {
@@ -1664,22 +1715,18 @@ function fraimInvocationFor(employeeId, jobId, kind) {
1664
1715
  // AND what we show in the timeline so the manager sees what the agent
1665
1716
  // received. For freeform jobs (no FRAIM job assigned), the instructions
1666
1717
  // are sent verbatim with no invocation prefix.
1667
- function buildAgentMessage(employeeId, jobId, kind, instructions, stubPath) {
1668
- const trimmed = (instructions || '').trim();
1669
- const invocation = fraimInvocationFor(employeeId, jobId, kind);
1670
- // Freeform: no FRAIM prefix, no stub reference — just the raw instructions.
1671
- if (!invocation) return trimmed;
1672
- const stub = (kind === 'start' && stubPath) ? `\n[Job stub: ${stubPath}]` : '';
1673
- if (!trimmed) return `${invocation}${stub}`;
1674
- // For continue turns, applyTemplateInvocation already writes the full
1675
- // FRAIM invocation (e.g. "/fraim follow-your-mentor") into the textarea.
1676
- // If the text already starts with a known FRAIM symbol, don't prepend again.
1677
- if (kind === 'continue') {
1678
- const knownSymbols = Object.values(FRAIM_INVOCATION_SYMBOL);
1679
- if (knownSymbols.some((s) => trimmed.startsWith(s))) return trimmed;
1680
- }
1681
- return `${invocation}${stub}\n\n${trimmed}`;
1682
- }
1718
+ function buildAgentMessage(employeeId, jobId, kind, instructions, stubPath) {
1719
+ const trimmed = (instructions || '').trim();
1720
+ const explicit = extractExplicitFraimInvocation(trimmed);
1721
+ const effectiveJobId = explicit?.jobId || jobId;
1722
+ const invocation = fraimInvocationFor(employeeId, effectiveJobId, kind);
1723
+ // Freeform: no FRAIM prefix, no stub reference just the raw instructions.
1724
+ if (!invocation) return explicit?.remainder || trimmed;
1725
+ const remainder = explicit ? explicit.remainder : trimmed;
1726
+ const stub = (kind === 'start' && stubPath) ? `\n[Job stub: ${stubPath}]` : '';
1727
+ if (!remainder) return `${invocation}${stub}`;
1728
+ return `${invocation}${stub}\n\n${remainder}`;
1729
+ }
1683
1730
 
1684
1731
  async function startRun(job, instructions, employeeId) {
1685
1732
  // Prefix the manager's typed instructions with the FRAIM invocation so
@@ -2021,17 +2068,25 @@ function wireEvents() {
2021
2068
  }
2022
2069
  });
2023
2070
 
2024
- if (els['active-employee-select']) {
2025
- els['active-employee-select'].addEventListener('change', () => {
2071
+ if (els['active-employee-select']) {
2072
+ els['active-employee-select'].addEventListener('change', () => {
2026
2073
  // Only update the global preference here. Do NOT update conv.employeeId —
2027
2074
  // the send handler compares sel.value vs conv.employeeId to detect a
2028
2075
  // switch; updating conv here would make them equal and the restart would
2029
2076
  // never fire.
2030
- state.selectedEmployeeId = els['active-employee-select'].value;
2031
- });
2032
- }
2033
-
2034
- // Issue #347 R2 — template picker.
2077
+ state.selectedEmployeeId = els['active-employee-select'].value;
2078
+ });
2079
+ }
2080
+
2081
+ if (els['coach-panel']) {
2082
+ els['coach-panel'].addEventListener('toggle', () => {
2083
+ const conv = activeConversation();
2084
+ if (!conv) return;
2085
+ panelStateFor(conv.id).coach = els['coach-panel'].open;
2086
+ });
2087
+ }
2088
+
2089
+ // Issue #347 R2 — template picker.
2035
2090
  if (els['template-picker-btn']) {
2036
2091
  els['template-picker-btn'].addEventListener('click', (e) => {
2037
2092
  e.stopPropagation();
@@ -388,9 +388,9 @@ button { font: inherit; cursor: pointer; }
388
388
  }
389
389
 
390
390
  .conv-header,
391
- .coach,
392
- .micro,
393
- .progress {
391
+ .support-stack,
392
+ .panel-details,
393
+ .micro {
394
394
  flex-shrink: 0;
395
395
  }
396
396
  .conv-topline {
@@ -482,24 +482,76 @@ button { font: inherit; cursor: pointer; }
482
482
 
483
483
  .conv-header h2 { margin: 0; font-size: 34px; font-weight: 700; letter-spacing: -0.03em; }
484
484
  .conv-job { color: var(--muted); font-size: 14px; margin-top: 2px; }
485
- .summary-strip {
486
- background: rgba(255, 255, 255, 0.56);
485
+ .conversation-status {
486
+ display: flex;
487
+ align-items: flex-start;
488
+ gap: 12px;
489
+ min-width: 0;
490
+ flex-shrink: 0;
491
+ }
492
+ .conversation-status > * {
493
+ min-width: 0;
494
+ }
495
+ .status-stack {
496
+ display: grid;
497
+ gap: 10px;
498
+ min-width: 0;
499
+ flex: 1 1 auto;
500
+ }
501
+ .support-stack {
502
+ display: grid;
503
+ gap: 8px;
504
+ }
505
+ .panel-details {
506
+ background: rgba(255, 255, 255, 0.4);
487
507
  border: 1px solid rgba(31, 67, 125, 0.08);
488
- border-radius: 18px;
489
- padding: 14px 16px;
508
+ border-radius: 22px;
509
+ overflow: hidden;
510
+ }
511
+ .panel-details > summary {
512
+ cursor: pointer;
513
+ list-style: none;
514
+ display: flex;
515
+ align-items: center;
516
+ gap: 10px;
517
+ padding: 10px 14px;
518
+ }
519
+ .panel-details > summary::-webkit-details-marker { display: none; }
520
+ .panel-details > summary::before {
521
+ content: "▸";
522
+ font-size: 11px;
523
+ color: var(--muted);
524
+ transition: transform 100ms ease;
525
+ flex-shrink: 0;
526
+ }
527
+ .panel-details[open] > summary::before { transform: rotate(90deg); }
528
+ .panel-summary-copy {
529
+ display: flex;
530
+ flex-direction: column;
531
+ gap: 2px;
532
+ min-width: 0;
533
+ }
534
+ .panel-kicker {
535
+ font-size: 11px;
536
+ font-weight: 700;
537
+ letter-spacing: 0.12em;
538
+ text-transform: uppercase;
539
+ color: var(--muted);
540
+ }
541
+ .panel-summary-text {
542
+ font-size: 13px;
490
543
  color: var(--text);
491
- font-size: 14px;
492
- line-height: 1.55;
493
- display: -webkit-box;
494
- -webkit-line-clamp: 4;
495
- -webkit-box-orient: vertical;
544
+ white-space: nowrap;
496
545
  overflow: hidden;
546
+ text-overflow: ellipsis;
497
547
  }
498
-
499
- .section-title {
500
- font-size: 13px;
501
- font-weight: 600;
502
- color: var(--muted);
548
+ .panel-body {
549
+ padding: 0 14px 12px;
550
+ }
551
+ .section-title {
552
+ font-size: 13px;
553
+ font-weight: 600;
554
+ color: var(--muted);
503
555
  text-transform: uppercase;
504
556
  letter-spacing: 0.05em;
505
557
  margin-bottom: 8px;
@@ -571,6 +623,15 @@ button { font: inherit; cursor: pointer; }
571
623
  0%, 100% { opacity: 1; transform: scale(1); }
572
624
  50% { opacity: 0.5; transform: scale(0.8); }
573
625
  }
626
+ .progress-inline {
627
+ flex: 1 1 auto;
628
+ }
629
+ .tracker-inline {
630
+ background: rgba(255, 255, 255, 0.42);
631
+ border: 1px solid rgba(31, 67, 125, 0.08);
632
+ border-radius: 18px;
633
+ padding: 8px 12px;
634
+ }
574
635
 
575
636
  .thread-surface {
576
637
  display: flex;
@@ -578,7 +639,7 @@ button { font: inherit; cursor: pointer; }
578
639
  gap: 14px;
579
640
  min-height: 0;
580
641
  flex: 1;
581
- padding: 18px 18px 16px;
642
+ padding: 22px 22px 18px;
582
643
  border-radius: 28px;
583
644
  border: 1px solid rgba(31, 67, 125, 0.1);
584
645
  background:
@@ -622,11 +683,11 @@ button { font: inherit; cursor: pointer; }
622
683
  }
623
684
  .message.manager {
624
685
  justify-items: end;
625
- padding-left: 24%;
686
+ padding-left: 30%;
626
687
  }
627
688
  .message.employee {
628
689
  justify-items: start;
629
- padding-right: 24%;
690
+ padding-right: 30%;
630
691
  }
631
692
  .message.system {
632
693
  justify-items: stretch;
@@ -687,11 +748,11 @@ button { font: inherit; cursor: pointer; }
687
748
  }
688
749
  .bubble {
689
750
  border-radius: 26px;
690
- padding: 16px 18px;
751
+ padding: 14px 16px;
691
752
  font-size: 14px;
692
- line-height: 1.6;
753
+ line-height: 1.55;
693
754
  box-shadow: 0 12px 26px rgba(35, 30, 23, 0.05);
694
- max-width: min(760px, 100%);
755
+ max-width: min(620px, 100%);
695
756
  }
696
757
  .message.manager .bubble {
697
758
  background: var(--accent);
@@ -750,8 +811,8 @@ button { font: inherit; cursor: pointer; }
750
811
 
751
812
  .coach textarea {
752
813
  width: 100%;
753
- min-height: 56px;
754
- max-height: 30vh;
814
+ min-height: 48px;
815
+ max-height: 22vh;
755
816
  resize: vertical;
756
817
  border: 1px solid var(--line);
757
818
  border-radius: 18px;
@@ -762,17 +823,17 @@ button { font: inherit; cursor: pointer; }
762
823
  }
763
824
  .coach textarea:focus { outline: none; border-color: var(--accent); }
764
825
  .coach {
765
- background: rgba(255, 255, 255, 0.38);
766
- border: 1px solid rgba(31, 67, 125, 0.08);
767
- border-radius: 22px;
768
- padding: 16px 18px;
826
+ background: transparent;
827
+ border: none;
828
+ border-radius: 0;
829
+ padding: 0;
769
830
  }
770
- .coach-actions { display: flex; justify-content: flex-end; margin-top: 10px; }
831
+ .coach-actions { display: flex; justify-content: flex-end; margin-top: 8px; }
771
832
  .coach-note {
772
- margin-top: 10px;
833
+ margin-top: 8px;
773
834
  color: var(--muted);
774
- font-size: 12px;
775
- line-height: 1.5;
835
+ font-size: 11px;
836
+ line-height: 1.4;
776
837
  }
777
838
  .send-button {
778
839
  background: var(--accent);
@@ -787,14 +848,11 @@ button { font: inherit; cursor: pointer; }
787
848
  .send-button:disabled { background: #c5d2cb; cursor: not-allowed; }
788
849
 
789
850
  .micro {
790
- margin-top: auto;
791
- border-top: 1px solid var(--line);
792
- padding-top: 14px;
851
+ margin-top: 0;
793
852
  position: sticky;
794
853
  bottom: 0;
795
- z-index: 4;
796
- background:
797
- linear-gradient(180deg, rgba(248, 244, 235, 0), rgba(248, 244, 235, 0.96) 22%);
854
+ z-index: 2;
855
+ background: rgba(255, 255, 255, 0.4);
798
856
  }
799
857
  .micro summary {
800
858
  cursor: pointer;
@@ -812,10 +870,10 @@ button { font: inherit; cursor: pointer; }
812
870
  transition: transform 100ms;
813
871
  }
814
872
  .micro[open] summary::before { transform: rotate(90deg); }
815
- .micro-log {
816
- margin-top: 12px;
817
- background: #1f2a24;
818
- color: #c5d2cb;
873
+ .micro-log {
874
+ margin: 0 16px 16px;
875
+ background: #1f2a24;
876
+ color: #c5d2cb;
819
877
  border-radius: 8px;
820
878
  padding: 12px 14px;
821
879
  font-family: Consolas, Menlo, monospace;
@@ -1025,6 +1083,9 @@ button.small { padding: 4px 10px; font-size: 12px; }
1025
1083
  .thread-surface {
1026
1084
  padding: 16px 14px 14px;
1027
1085
  }
1086
+ .panel-summary-text {
1087
+ white-space: normal;
1088
+ }
1028
1089
  .message,
1029
1090
  .message.manager,
1030
1091
  .message.employee,
@@ -1217,14 +1278,14 @@ button.small { padding: 4px 10px; font-size: 12px; }
1217
1278
 
1218
1279
  /* Totals line (R4). 12px muted, no border, single row that wraps if it
1219
1280
  absolutely must. Discoverable via hover tooltips per spec R4.4. */
1220
- .totals {
1221
- font-size: 12px;
1222
- color: var(--muted);
1223
- padding-top: 4px;
1224
- display: flex;
1225
- gap: 14px;
1226
- flex-wrap: wrap;
1227
- }
1281
+ .totals {
1282
+ font-size: 11px;
1283
+ color: var(--muted);
1284
+ padding-top: 2px;
1285
+ display: flex;
1286
+ gap: 10px;
1287
+ flex-wrap: wrap;
1288
+ }
1228
1289
  .totals span { cursor: help; }
1229
1290
  .totals .sep { color: var(--line); }
1230
1291
  .totals strong { color: var(--text); font-weight: 600; }