fraim 2.0.177 → 2.0.180

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 (77) hide show
  1. package/dist/src/ai-hub/desktop-main.js +2 -2
  2. package/dist/src/ai-hub/server.js +50 -1
  3. package/dist/src/api/admin/payments.js +33 -0
  4. package/dist/src/api/admin/sales-leads.js +21 -0
  5. package/dist/src/api/payment/create-session.js +338 -0
  6. package/dist/src/api/payment/dashboard-link.js +149 -0
  7. package/dist/src/api/payment/session-details.js +31 -0
  8. package/dist/src/api/payment/webhook.js +587 -0
  9. package/dist/src/api/personas/me.js +29 -0
  10. package/dist/src/api/pricing/get-config.js +25 -0
  11. package/dist/src/api/sales/contact.js +44 -0
  12. package/dist/src/cli/commands/add-provider.js +74 -61
  13. package/dist/src/cli/commands/add-surface.js +128 -0
  14. package/dist/src/cli/commands/login.js +5 -69
  15. package/dist/src/cli/commands/setup.js +27 -347
  16. package/dist/src/cli/distribution/marketplace-bundles.js +580 -0
  17. package/dist/src/cli/fraim.js +2 -0
  18. package/dist/src/cli/mcp/ide-formats.js +5 -3
  19. package/dist/src/cli/mcp/mcp-server-registry.js +10 -3
  20. package/dist/src/cli/providers/local-provider-registry.js +2 -3
  21. package/dist/src/cli/setup/auto-mcp-setup.js +9 -32
  22. package/dist/src/cli/setup/ide-detector.js +34 -14
  23. package/dist/src/config/persona-capability-bundles.js +17 -13
  24. package/dist/src/db/payment-repository.js +61 -0
  25. package/dist/src/first-run/session-service.js +2 -2
  26. package/dist/src/fraim/config-loader.js +11 -0
  27. package/dist/src/fraim/db-service.js +2387 -0
  28. package/dist/src/fraim/issues.js +152 -0
  29. package/dist/src/fraim/template-processor.js +184 -0
  30. package/dist/src/fraim/utils/request-utils.js +23 -0
  31. package/dist/src/local-mcp-server/stdio-server.js +28 -4
  32. package/dist/src/local-mcp-server/usage-collector.js +24 -0
  33. package/dist/src/middleware/auth.js +266 -0
  34. package/dist/src/middleware/cors-config.js +111 -0
  35. package/dist/src/middleware/logger.js +116 -0
  36. package/dist/src/middleware/rate-limit.js +110 -0
  37. package/dist/src/middleware/reject-query-api-key.js +45 -0
  38. package/dist/src/middleware/security-headers.js +41 -0
  39. package/dist/src/middleware/telemetry.js +134 -0
  40. package/dist/src/models/payment.js +2 -0
  41. package/dist/src/routes/analytics.js +1447 -0
  42. package/dist/src/routes/app-routes.js +32 -0
  43. package/dist/src/routes/auth-routes.js +505 -0
  44. package/dist/src/routes/oauth-routes.js +325 -0
  45. package/dist/src/routes/payment-routes.js +186 -0
  46. package/dist/src/routes/persona-catalog-routes.js +84 -0
  47. package/dist/src/services/admin-service.js +229 -0
  48. package/dist/src/services/audit-log-persistence.js +60 -0
  49. package/dist/src/services/audit-log.js +69 -0
  50. package/dist/src/services/cookie-service.js +129 -0
  51. package/dist/src/services/dashboard-access.js +27 -0
  52. package/dist/src/services/demo-seed-service.js +139 -0
  53. package/dist/src/services/email-code.js +23 -0
  54. package/dist/src/services/email-service-clean.js +782 -0
  55. package/dist/src/services/email-service.js +951 -0
  56. package/dist/src/services/installer-service.js +131 -0
  57. package/dist/src/services/mcp-oauth-store.js +33 -0
  58. package/dist/src/services/mcp-service.js +823 -0
  59. package/dist/src/services/oauth-helpers.js +127 -0
  60. package/dist/src/services/org-service.js +89 -0
  61. package/dist/src/services/persona-entitlement-service.js +288 -0
  62. package/dist/src/services/provider-service.js +215 -0
  63. package/dist/src/services/registry-service.js +628 -0
  64. package/dist/src/services/session-service.js +86 -0
  65. package/dist/src/services/trial-reminder-service.js +120 -0
  66. package/dist/src/services/usage-analytics-service.js +419 -0
  67. package/dist/src/services/workspace-identity.js +21 -0
  68. package/dist/src/types/analytics.js +2 -0
  69. package/dist/src/utils/payment-calculator.js +52 -0
  70. package/extensions/office-word/favicon.ico +0 -0
  71. package/extensions/office-word/icon-64.png +0 -0
  72. package/extensions/office-word/manifest.xml +33 -0
  73. package/extensions/office-word/taskpane.html +242 -0
  74. package/package.json +14 -3
  75. package/public/ai-hub/index.html +14 -2
  76. package/public/ai-hub/script.js +340 -66
  77. package/public/ai-hub/styles.css +83 -0
@@ -1002,6 +1002,7 @@ function statusLabel(s) {
1002
1002
 
1003
1003
  function conversationUiState(conv) {
1004
1004
  if (!conv) return 'idle';
1005
+ if (conv.blocked) return 'blocked';
1005
1006
  if (conv.status === 'running') return 'working';
1006
1007
  // #549: conv.stopped is set by tfStopRun after a manager-initiated stop.
1007
1008
  // A stopped run is an intentional pause — visually distinct from an error-failed run.
@@ -1026,6 +1027,7 @@ function conversationUiState(conv) {
1026
1027
 
1027
1028
  function conversationStateDotClass(conv) {
1028
1029
  const uiState = conversationUiState(conv);
1030
+ if (uiState === 'blocked') return 'warn';
1029
1031
  if (uiState === 'working') return 'amber';
1030
1032
  if (uiState === 'waiting') return 'red';
1031
1033
  if (uiState === 'complete') return 'green';
@@ -1042,6 +1044,7 @@ function conversationStateLabel(conv) {
1042
1044
  if (conv.status === 'failed') return 'Needs Mandy';
1043
1045
  }
1044
1046
  const uiState = conversationUiState(conv);
1047
+ if (uiState === 'blocked') return 'Blocked';
1045
1048
  if (uiState === 'working') return 'Working';
1046
1049
  if (uiState === 'waiting') return 'Waiting on you';
1047
1050
  if (uiState === 'complete') return 'Done';
@@ -4010,11 +4013,19 @@ function renderCpRows(searchText) {
4010
4013
  const q = query.toLowerCase();
4011
4014
 
4012
4015
  // Filter catalog — no PAGE_SCOPED_JOBS exclusion; all jobs are reachable via the palette
4013
- const catalogJobs = jobs.filter((j) => {
4016
+ let catalogJobs = jobs.filter((j) => {
4014
4017
  if (personaFilter !== null && (j.requiredPersonaKey || '').toLowerCase() !== personaFilter) return false;
4015
4018
  if (q && !(j.title.toLowerCase().includes(q) || j.id.toLowerCase().includes(q) || (j.intent || '').toLowerCase().includes(q))) return false;
4016
4019
  return true;
4017
4020
  });
4021
+ // Personas with no specific jobs (e.g. maestro) should fall back to general jobs
4022
+ if (personaFilter !== null && catalogJobs.length === 0) {
4023
+ catalogJobs = jobs.filter((j) => {
4024
+ if (j.requiredPersonaKey) return false;
4025
+ if (q && !(j.title.toLowerCase().includes(q) || j.id.toLowerCase().includes(q) || (j.intent || '').toLowerCase().includes(q))) return false;
4026
+ return true;
4027
+ });
4028
+ }
4018
4029
 
4019
4030
  // Resolve recent rows (up to 3, only from jobs still in catalog)
4020
4031
  const recentRows = [];
@@ -5168,8 +5179,13 @@ function renderPersonaGrid() {
5168
5179
  hireBtn.type = 'button';
5169
5180
  hireBtn.className = 'btn-hire-primary';
5170
5181
  hireBtn.dataset.personaKey = persona.key;
5171
- hireBtn.textContent = persona.seatCount > 0 ? 'Add more' : 'Add to Company';
5172
- hireBtn.addEventListener('click', () => openHireCheckout(persona.key, 'fulltime'));
5182
+ if (persona.seatCount > 0) {
5183
+ hireBtn.textContent = 'Start a job';
5184
+ hireBtn.addEventListener('click', () => openPalette({ prefixSearch: '/' + persona.key }));
5185
+ } else {
5186
+ hireBtn.textContent = 'Add to Company';
5187
+ hireBtn.addEventListener('click', () => openHireCheckout(persona.key, 'fulltime'));
5188
+ }
5173
5189
 
5174
5190
  card.appendChild(dot);
5175
5191
  card.appendChild(avatar);
@@ -5955,6 +5971,8 @@ function tfAvatarFor(key, index) {
5955
5971
  function tfEscape(s) {
5956
5972
  return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
5957
5973
  }
5974
+ // Issue #671: Done column time filter — module-level so filter choice survives re-renders
5975
+ let kbDoneFilter = '24h';
5958
5976
 
5959
5977
  function tfPersonas() { return (state.bootstrap && state.bootstrap.personas) || []; }
5960
5978
  function tfHiredPersonas() { return tfPersonas().filter((p) => p.status === 'hired'); }
@@ -6213,9 +6231,7 @@ function tfRenderOverview() {
6213
6231
  const name = document.createElement('div');
6214
6232
  name.className = 'proj-name';
6215
6233
  name.textContent = proj.name;
6216
- const brief = document.createElement('div');
6217
- brief.className = 'proj-brief';
6218
- brief.textContent = proj.brief || proj.intent || 'No brief yet.';
6234
+ const briefText = proj.brief || proj.intent || '';
6219
6235
  const team = document.createElement('div');
6220
6236
  team.className = 'proj-team';
6221
6237
  const assigned = tfProjectAssignments(proj.id);
@@ -6235,12 +6251,33 @@ function tfRenderOverview() {
6235
6251
  chip.appendChild(pip);
6236
6252
  team.appendChild(chip);
6237
6253
  });
6238
- const waiting = assigned.filter((a) => tfAssignmentDot(a) === 'red').length;
6254
+ // Look up conversations for this project; fall back to state.projectPath for the active project
6255
+ const convKey = (state.conversations[proj.folderPath] ? proj.folderPath
6256
+ : (tf.activeProjectId === proj.id ? state.projectPath : null));
6257
+ const projConvs = (convKey ? state.conversations[convKey] : []).filter((c) => !c.managedByRunId);
6258
+ const runCount = projConvs.filter((c) => conversationUiState(c) === 'working').length;
6259
+ const waitCount = projConvs.filter((c) => ['waiting', 'stopped'].includes(conversationUiState(c))).length;
6260
+ const blockCount = projConvs.filter((c) => c.blocked).length;
6261
+ const totalRuns = projConvs.length;
6262
+ const parts = [];
6263
+ if (blockCount) parts.push(blockCount + ' blocked');
6264
+ if (runCount) parts.push(runCount + ' in progress');
6265
+ if (waitCount) parts.push(waitCount + ' waiting');
6239
6266
  const badge = document.createElement('span');
6240
- badge.className = 'proj-badge' + (waiting ? '' : ' quiet');
6241
- badge.textContent = waiting ? (waiting + ' waiting') : 'Active';
6267
+ const needsAttention = waitCount > 0 || blockCount > 0;
6268
+ badge.className = 'proj-badge' + (needsAttention ? '' : ' quiet');
6269
+ badge.textContent = parts.length ? parts.join(' · ')
6270
+ : totalRuns ? totalRuns + (totalRuns === 1 ? ' run' : ' runs')
6271
+ : 'No runs yet';
6242
6272
  team.appendChild(badge);
6243
- card.appendChild(name); card.appendChild(brief); card.appendChild(team);
6273
+ card.appendChild(name);
6274
+ if (briefText) {
6275
+ const brief = document.createElement('div');
6276
+ brief.className = 'proj-brief';
6277
+ brief.textContent = briefText;
6278
+ card.appendChild(brief);
6279
+ }
6280
+ card.appendChild(team);
6244
6281
  card.addEventListener('click', () => tfSelectProjectView('workspace', proj.id));
6245
6282
  grid.appendChild(card);
6246
6283
  }
@@ -6249,6 +6286,192 @@ function tfRenderOverview() {
6249
6286
  addCard.textContent = '+ New project';
6250
6287
  addCard.addEventListener('click', tfOpenNewProject);
6251
6288
  grid.appendChild(addCard);
6289
+ // Issue #671 R1: default to Kanban when 3+ projects; respect stored user preference
6290
+ const storedView = window.localStorage.getItem('fraim-overview-view');
6291
+ tfSwitchOverviewView(storedView || (tf.projects.length >= 3 ? 'kanban' : 'cards'));
6292
+ }
6293
+
6294
+ // ---------------------------------------------------------------------------
6295
+ // Issue #671: Kanban view for Projects Overview
6296
+ // ---------------------------------------------------------------------------
6297
+
6298
+ function tfSwitchOverviewView(view) {
6299
+ const cardsView = document.getElementById('proj-cards-view');
6300
+ const kanbanView = document.getElementById('proj-kanban');
6301
+ if (cardsView) cardsView.hidden = (view !== 'cards');
6302
+ if (kanbanView) kanbanView.hidden = (view !== 'kanban');
6303
+ document.querySelectorAll('#kb-view-toggle .kb-vtab').forEach((btn) => {
6304
+ btn.classList.toggle('on', btn.dataset.view === view);
6305
+ });
6306
+ window.localStorage.setItem('fraim-overview-view', view);
6307
+ if (view === 'kanban') tfRenderKanban();
6308
+ }
6309
+
6310
+ function tfRenderKanban() {
6311
+ const kanban = document.getElementById('proj-kanban');
6312
+ if (!kanban) return;
6313
+
6314
+ const allConvs = Object.values(state.conversations || {}).flat().filter((c) => c && !c.managedByRunId);
6315
+
6316
+ const now = Date.now();
6317
+ const doneMs = kbDoneFilter === '7d' ? 7 * 24 * 60 * 60 * 1000 :
6318
+ kbDoneFilter === 'all' ? Infinity : 24 * 60 * 60 * 1000;
6319
+ const cols = { 'in-progress': [], waiting: [], done: [], blocked: [] };
6320
+
6321
+ for (const conv of allConvs) {
6322
+ if (conv.blocked) { cols.blocked.push(conv); continue; }
6323
+ const uiState = conversationUiState(conv);
6324
+ if (uiState === 'working') cols['in-progress'].push(conv);
6325
+ else if (uiState === 'waiting' || uiState === 'stopped') cols.waiting.push(conv);
6326
+ else if (uiState === 'complete' && now - (conv.lastUpdatedAt || 0) <= doneMs) cols.done.push(conv);
6327
+ }
6328
+
6329
+ const statsEl = document.getElementById('kb-stats');
6330
+ if (statsEl) {
6331
+ const wc = cols.waiting.length;
6332
+ const rc = cols['in-progress'].length;
6333
+ const bc = cols.blocked.length;
6334
+ statsEl.innerHTML = '';
6335
+ const addPart = (cls, txt) => {
6336
+ const s = document.createElement('span'); s.className = cls; s.textContent = txt; statsEl.appendChild(s);
6337
+ };
6338
+ addPart('kb-stats-waiting', wc + ' waiting for you');
6339
+ addPart('kb-stats-sep', ' · ');
6340
+ addPart('kb-stats-running', rc + ' running');
6341
+ if (bc > 0) { addPart('kb-stats-sep', ' · '); addPart('kb-stats-blocked', bc + ' blocked'); }
6342
+ }
6343
+
6344
+ let board = kanban.querySelector('.kb-board');
6345
+ if (!board) { board = document.createElement('div'); board.className = 'kb-board'; kanban.appendChild(board); }
6346
+ board.innerHTML = '';
6347
+
6348
+ const colDefs = [
6349
+ { key: 'in-progress', label: 'In Progress' },
6350
+ { key: 'waiting', label: 'Waiting for You' },
6351
+ { key: 'done', label: 'Done' },
6352
+ { key: 'blocked', label: 'Blocked' },
6353
+ ];
6354
+
6355
+ for (const def of colDefs) {
6356
+ const convs = cols[def.key];
6357
+ const col = document.createElement('div');
6358
+ col.className = 'kb-col' + (def.key === 'blocked' && convs.length === 0 ? ' kb-col--empty' : '');
6359
+ col.dataset.col = def.key;
6360
+
6361
+ const hdr = document.createElement('div'); hdr.className = 'kb-col-header';
6362
+ const titleEl = document.createElement('span'); titleEl.className = 'kb-col-title'; titleEl.textContent = def.label; hdr.appendChild(titleEl);
6363
+ const cntEl = document.createElement('span'); cntEl.className = 'kb-col-count'; cntEl.textContent = String(convs.length); hdr.appendChild(cntEl);
6364
+
6365
+ if (def.key === 'done') {
6366
+ const sel = document.createElement('select'); sel.className = 'kb-done-filter';
6367
+ [['24h', 'Last 24h'], ['7d', 'Last 7d'], ['all', 'All time']].forEach(([v, l]) => {
6368
+ const opt = document.createElement('option'); opt.value = v; opt.textContent = l; opt.selected = (v === kbDoneFilter); sel.appendChild(opt);
6369
+ });
6370
+ sel.addEventListener('change', () => { kbDoneFilter = sel.value; tfRenderKanban(); });
6371
+ hdr.appendChild(sel);
6372
+ }
6373
+
6374
+ col.appendChild(hdr);
6375
+ const cardsEl = document.createElement('div'); cardsEl.className = 'kb-cards';
6376
+ for (const conv of convs) cardsEl.appendChild(kbMakeCard(conv));
6377
+ col.appendChild(cardsEl);
6378
+ board.appendChild(col);
6379
+ }
6380
+ }
6381
+
6382
+ function kbMakeCard(conv) {
6383
+ const card = document.createElement('div'); card.className = 'kb-card';
6384
+
6385
+ const job = document.createElement('div'); job.className = 'kb-job';
6386
+ job.textContent = conv.jobTitle || conv.jobId || '';
6387
+ card.appendChild(job);
6388
+
6389
+ const meta = document.createElement('div'); meta.className = 'kb-meta';
6390
+
6391
+ const projName = (conv.projectPath || '').split(/[/\\]/).filter(Boolean).pop() || 'Project';
6392
+ const chip = document.createElement('span'); chip.className = 'kb-proj-chip'; chip.textContent = projName; meta.appendChild(chip);
6393
+
6394
+ const uiState = conv.blocked ? 'blocked' : conversationUiState(conv);
6395
+ if (uiState === 'waiting') {
6396
+ const b = document.createElement('span'); b.className = 'kb-badge kb-waiting'; b.textContent = 'Waiting'; meta.appendChild(b);
6397
+ } else if (uiState === 'stopped') {
6398
+ const b = document.createElement('span'); b.className = 'kb-badge kb-stopped'; b.textContent = 'Stopped'; meta.appendChild(b);
6399
+ }
6400
+
6401
+ const avRow = document.createElement('div'); avRow.className = 'kb-av-row';
6402
+ const empKey = conv.personaKey || conv.agentName || '';
6403
+ if (empKey) {
6404
+ const persona = tfPersonaByKey(empKey);
6405
+ const avData = tfAvatarFor(persona ? persona.displayName : empKey, 0);
6406
+ const av = document.createElement('div');
6407
+ av.className = 'av'; av.style.background = avData.color; av.style.color = '#fff'; av.style.fontSize = '10px'; av.textContent = avData.badge;
6408
+ const pip = document.createElement('span'); pip.className = 'pip';
6409
+ pip.style.background = 'var(--' + conversationStateDotClass(conv) + ')'; av.appendChild(pip);
6410
+ avRow.appendChild(av);
6411
+ }
6412
+ meta.appendChild(avRow);
6413
+ card.appendChild(meta);
6414
+
6415
+ const age = document.createElement('div'); age.className = 'kb-age'; age.textContent = kbAgeLabel(conv); card.appendChild(age);
6416
+
6417
+ // Kanban/Cards overview is read-only. Clicking anywhere on the card navigates to the workspace.
6418
+ const navigate = () => {
6419
+ if (!conv.id) return;
6420
+ const _proj = conv.projectPath ? tf.projects.find((p) => p.folderPath === conv.projectPath) : null;
6421
+ tfSelectProjectView('workspace', _proj ? _proj.id : tf.activeProjectId);
6422
+ switchToConversation(conv.id);
6423
+ };
6424
+ card.style.cursor = 'pointer';
6425
+ card.addEventListener('click', navigate);
6426
+ const actions = document.createElement('div'); actions.className = 'kb-actions';
6427
+ const o = document.createElement('button'); o.type = 'button'; o.className = 'kb-chip'; o.textContent = 'Open →';
6428
+ o.addEventListener('click', (e) => { e.stopPropagation(); navigate(); });
6429
+ actions.appendChild(o);
6430
+ card.appendChild(actions);
6431
+ return card;
6432
+ }
6433
+
6434
+ function kbAgeLabel(conv) {
6435
+ const ts = conv.lastUpdatedAt || 0;
6436
+ if (!ts) return '';
6437
+ const diff = Date.now() - ts;
6438
+ const mins = Math.floor(diff / 60000);
6439
+ const hrs = Math.floor(diff / 3600000);
6440
+ const days = Math.floor(diff / 86400000);
6441
+ const elapsed = days > 0 ? days + 'd ago' : hrs > 0 ? hrs + 'h ago' : mins + 'm ago';
6442
+ const s = conversationUiState(conv);
6443
+ if (s === 'blocked') return 'Blocked ' + elapsed;
6444
+ if (s === 'working') return 'Running ' + elapsed;
6445
+ if (s === 'stopped') return 'Stopped ' + elapsed;
6446
+ if (s === 'waiting') return 'Waiting ' + elapsed;
6447
+ if (s === 'complete') return 'Done ' + elapsed;
6448
+ return elapsed;
6449
+ }
6450
+
6451
+ function tfRenderRunsCards() {
6452
+ const runsGrid = document.getElementById('kb-runs-grid');
6453
+ if (!runsGrid) return;
6454
+ // Hide project cards; show runs grid
6455
+ const projGrid = document.getElementById('proj-grid');
6456
+ const label = document.getElementById('proj-cards-label');
6457
+ if (projGrid) projGrid.hidden = true;
6458
+ if (label) label.hidden = true;
6459
+ runsGrid.hidden = false;
6460
+ runsGrid.innerHTML = '';
6461
+ const allConvs = Object.values(state.conversations || {}).flat()
6462
+ .filter((c) => c && !c.managedByRunId)
6463
+ .sort((a, b) => (b.lastUpdatedAt || 0) - (a.lastUpdatedAt || 0));
6464
+ if (!allConvs.length) {
6465
+ const empty = document.createElement('div');
6466
+ empty.className = 'kb-empty-state';
6467
+ empty.textContent = 'No runs yet — start a job from a project workspace.';
6468
+ runsGrid.appendChild(empty);
6469
+ return;
6470
+ }
6471
+ const grid = document.createElement('div');
6472
+ grid.className = 'kb-grid';
6473
+ for (const conv of allConvs) grid.appendChild(kbMakeCard(conv));
6474
+ runsGrid.appendChild(grid);
6252
6475
  }
6253
6476
 
6254
6477
  // ---------------------------------------------------------------------------
@@ -8840,10 +9063,9 @@ async function tfSwitchProjectFolder(folderPath) {
8840
9063
  // Assign-job modal (job-first with locks + per-employee filter)
8841
9064
  // ---------------------------------------------------------------------------
8842
9065
  function tfJobsForPersona(jobs, personaKey) {
8843
- return jobs.filter((job) => {
8844
- if (!job.requiredPersonaKey) return true;
8845
- return job.requiredPersonaKey === personaKey;
8846
- });
9066
+ const specific = jobs.filter((job) => job.requiredPersonaKey === personaKey);
9067
+ const general = jobs.filter((job) => !job.requiredPersonaKey);
9068
+ return [...specific, ...general];
8847
9069
  }
8848
9070
 
8849
9071
  function tfOpenAssignJob(employeeKey) {
@@ -8851,67 +9073,112 @@ function tfOpenAssignJob(employeeKey) {
8851
9073
  const title = document.getElementById('aj-title');
8852
9074
  const body = document.getElementById('aj-body');
8853
9075
  if (!modal || !body) return;
8854
- body.innerHTML = '';
9076
+
8855
9077
  const hired = tfHiredPersonas();
8856
9078
  const filterKey = employeeKey || null;
8857
- const filterName = filterKey ? ((tfPersonaByKey(filterKey) || {}).displayName || filterKey) : null;
8858
- if (title) title.textContent = filterKey ? ('Assign a job to ' + filterName) : 'Assign a job';
9079
+ const filterPersona = filterKey ? tfPersonaByKey(filterKey) : null;
9080
+ const filterName = filterPersona ? (filterPersona.displayName || filterKey) : null;
9081
+ if (title) title.textContent = filterKey ? ('Jobs for ' + filterName) : 'Assign a job';
8859
9082
 
8860
- const readyLabel = document.createElement('div');
8861
- readyLabel.className = 'cat-label';
8862
- readyLabel.textContent = filterKey ? (filterName + "'s jobs") : 'Ready — your team can take these';
8863
- body.appendChild(readyLabel);
8864
-
8865
- const jobs = (state.bootstrap && state.bootstrap.jobs) || [];
9083
+ const allJobs = (state.bootstrap && state.bootstrap.jobs) || [];
8866
9084
  const personasToShow = filterKey ? hired.filter((p) => p.key === filterKey) : hired;
8867
- let anyReady = false;
8868
- personasToShow.forEach((persona, i) => {
8869
- const av = tfAvatarFor(persona.displayName, i);
8870
- tfJobsForPersona(jobs, persona.key).slice(0, 6).forEach((job) => {
9085
+
9086
+ body.innerHTML = '';
9087
+ const searchWrap = document.createElement('div');
9088
+ searchWrap.className = 'aj-search-wrap';
9089
+ const searchInput = document.createElement('input');
9090
+ searchInput.type = 'text';
9091
+ searchInput.className = 'aj-search';
9092
+ searchInput.placeholder = 'Search jobs…';
9093
+ searchInput.autocomplete = 'off';
9094
+ searchWrap.appendChild(searchInput);
9095
+ body.appendChild(searchWrap);
9096
+
9097
+ const listWrap = document.createElement('div');
9098
+ listWrap.className = 'aj-list-wrap';
9099
+ body.appendChild(listWrap);
9100
+
9101
+ const buildJobRow = function(av, job, persona) {
9102
+ const row = document.createElement('div');
9103
+ row.className = 'job-row';
9104
+ row.innerHTML = '<div class="jr-av" style="background:' + av.color + ';">' + av.badge + '</div>' +
9105
+ '<div class="jr-body"><div class="jr-name">' + tfEscape(job.title || job.id) + '</div>' +
9106
+ '<div class="jr-who">' + tfEscape(persona.displayName) + ' \xb7 ' + tfEscape(persona.role || '') + '</div></div>';
9107
+ const btn = document.createElement('button');
9108
+ btn.className = 'jr-action';
9109
+ btn.type = 'button';
9110
+ btn.textContent = 'Assign';
9111
+ btn.addEventListener('click', () => tfAssignJobToEmployee(persona.key, job));
9112
+ row.appendChild(btn);
9113
+ return row;
9114
+ };
9115
+
9116
+ const renderList = function(q) {
9117
+ listWrap.innerHTML = '';
9118
+ const lq = (q || '').toLowerCase().trim();
9119
+ const matchJob = function(j) {
9120
+ return !lq || (j.title || j.id).toLowerCase().includes(lq) || (j.intent || '').toLowerCase().includes(lq);
9121
+ };
9122
+ let anyReady = false;
9123
+
9124
+ personasToShow.forEach(function(persona, i) {
9125
+ const av = tfAvatarFor(persona.displayName, i);
9126
+ const specificJobs = allJobs.filter(function(j) { return j.requiredPersonaKey === persona.key; }).filter(matchJob);
9127
+ const generalJobs = allJobs.filter(function(j) { return !j.requiredPersonaKey; }).filter(matchJob);
9128
+
9129
+ if (specificJobs.length === 0 && generalJobs.length === 0) return;
8871
9130
  anyReady = true;
8872
- const row = document.createElement('div');
8873
- row.className = 'job-row';
8874
- row.innerHTML = '<div class="jr-av" style="background:' + av.color + ';">' + av.badge + '</div>' +
8875
- '<div class="jr-body"><div class="jr-name">' + tfEscape(job.title || job.id) + '</div>' +
8876
- '<div class="jr-who">' + tfEscape(persona.displayName) + ' · ' + tfEscape(persona.role || '') + '</div></div>';
8877
- const btn = document.createElement('button');
8878
- btn.className = 'jr-action';
8879
- btn.type = 'button';
8880
- btn.textContent = 'Assign';
8881
- btn.addEventListener('click', () => tfAssignJobToEmployee(persona.key, job));
8882
- row.appendChild(btn);
8883
- body.appendChild(row);
9131
+
9132
+ if (specificJobs.length > 0) {
9133
+ const specLabel = document.createElement('div');
9134
+ specLabel.className = 'cat-label';
9135
+ specLabel.textContent = (filterName || persona.displayName) + '’s specialty';
9136
+ listWrap.appendChild(specLabel);
9137
+ specificJobs.forEach(function(job) { listWrap.appendChild(buildJobRow(av, job, persona)); });
9138
+ }
9139
+
9140
+ if (generalJobs.length > 0) {
9141
+ const genLabel = document.createElement('div');
9142
+ genLabel.className = 'cat-label aj-shared-label';
9143
+ genLabel.textContent = specificJobs.length > 0 ? 'Shared jobs' : 'Available jobs';
9144
+ listWrap.appendChild(genLabel);
9145
+ generalJobs.forEach(function(job) { listWrap.appendChild(buildJobRow(av, job, persona)); });
9146
+ }
8884
9147
  });
8885
- });
8886
- if (!anyReady) {
8887
- const none = document.createElement('div');
8888
- none.style.cssText = 'font-size:13px;color:var(--sub);padding:10px 0;';
8889
- none.textContent = filterKey ? 'No ready jobs for this employee yet.' : 'No hired employees yet — hire your team to assign jobs.';
8890
- body.appendChild(none);
8891
- }
8892
9148
 
8893
- // Locked jobs (job-first only).
8894
- if (!filterKey) {
8895
- const locked = tfAvailablePersonas();
8896
- if (locked.length) {
8897
- const lockLabel = document.createElement('div');
8898
- lockLabel.className = 'cat-label';
8899
- lockLabel.textContent = 'Locked — hire the specialist to unlock';
8900
- body.appendChild(lockLabel);
8901
- locked.forEach((persona) => {
8902
- const av = tfAvatarFor(persona.displayName, 0);
8903
- const row = document.createElement('div');
8904
- row.className = 'job-row locked';
8905
- row.innerHTML = '<div class="jr-av">' + av.badge + '</div>' +
8906
- '<div class="jr-body"><div class="jr-name">' + tfEscape(persona.role || persona.displayName) + ' work</div>' +
8907
- '<div class="jr-who">Needs ' + tfEscape(persona.displayName) + ' · ' + tfEscape(persona.role || '') + '</div></div>' +
8908
- '<span class="jr-lock">🔒 Hire to assign</span>';
8909
- row.addEventListener('click', () => tfLockedJobClick(persona.key));
8910
- body.appendChild(row);
8911
- });
9149
+ if (!anyReady) {
9150
+ const none = document.createElement('div');
9151
+ none.className = 'aj-empty';
9152
+ none.textContent = lq ? 'No matching jobs.' : (filterKey ? 'No jobs available.' : 'No hired employees yet.');
9153
+ listWrap.appendChild(none);
8912
9154
  }
8913
- }
9155
+
9156
+ if (!filterKey && !lq) {
9157
+ const locked = tfAvailablePersonas();
9158
+ if (locked.length) {
9159
+ const lockLabel = document.createElement('div');
9160
+ lockLabel.className = 'cat-label';
9161
+ lockLabel.textContent = 'Locked: hire the specialist to unlock';
9162
+ listWrap.appendChild(lockLabel);
9163
+ locked.forEach(function(persona) {
9164
+ const av = tfAvatarFor(persona.displayName, 0);
9165
+ const row = document.createElement('div');
9166
+ row.className = 'job-row locked';
9167
+ row.innerHTML = '<div class="jr-av">' + av.badge + '</div>' +
9168
+ '<div class="jr-body"><div class="jr-name">' + tfEscape(persona.role || persona.displayName) + ' work</div>' +
9169
+ '<div class="jr-who">Needs ' + tfEscape(persona.displayName) + ' \xb7 ' + tfEscape(persona.role || '') + '</div></div>' +
9170
+ '<span class="jr-lock">🔒 Hire to assign</span>';
9171
+ row.addEventListener('click', function() { tfLockedJobClick(persona.key); });
9172
+ listWrap.appendChild(row);
9173
+ });
9174
+ }
9175
+ }
9176
+ };
9177
+
9178
+ renderList('');
9179
+ searchInput.addEventListener('input', function() { renderList(searchInput.value); });
8914
9180
  modal.hidden = false;
9181
+ setTimeout(function() { searchInput.focus(); }, 50);
8915
9182
  }
8916
9183
  function tfCloseAssignJob() {
8917
9184
  const m = document.getElementById('assign-job-modal');
@@ -9128,6 +9395,13 @@ function tfWireShell() {
9128
9395
  if (plBack) plBack.addEventListener('click', tfHideProjectLearnings);
9129
9396
  const overviewTab = document.getElementById('ptab-overview');
9130
9397
  if (overviewTab) overviewTab.addEventListener('click', () => tfSelectProjectView('overview'));
9398
+ // Issue #671: wire Cards / Kanban view toggle
9399
+ document.querySelectorAll('#kb-view-toggle .kb-vtab').forEach((btn) => {
9400
+ if (!btn.dataset.wired) {
9401
+ btn.dataset.wired = '1';
9402
+ btn.addEventListener('click', () => tfSwitchOverviewView(btn.dataset.view));
9403
+ }
9404
+ });
9131
9405
  const addTab = document.getElementById('ptab-add');
9132
9406
  if (addTab) addTab.addEventListener('click', tfOpenNewProject);
9133
9407
  const npClose = document.getElementById('np-close');
@@ -4216,6 +4216,49 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
4216
4216
  color: var(--muted);
4217
4217
  }
4218
4218
 
4219
+ /* ── assign-job modal: search + scrollable list ── */
4220
+ #assign-job-modal .modal-card {
4221
+ max-height: calc(100vh - 80px);
4222
+ display: flex;
4223
+ flex-direction: column;
4224
+ }
4225
+ #assign-job-modal .modal-body {
4226
+ flex: 1;
4227
+ overflow: hidden;
4228
+ padding: 0;
4229
+ display: flex;
4230
+ flex-direction: column;
4231
+ }
4232
+ .aj-search-wrap {
4233
+ padding: 12px 22px 10px;
4234
+ border-bottom: 1px solid var(--line);
4235
+ flex-shrink: 0;
4236
+ }
4237
+ .aj-search {
4238
+ width: 100%;
4239
+ border: 1px solid var(--line);
4240
+ background: var(--bg);
4241
+ color: var(--text);
4242
+ font-size: 14px;
4243
+ border-radius: 8px;
4244
+ padding: 8px 12px;
4245
+ outline: none;
4246
+ transition: border-color .15s;
4247
+ }
4248
+ .aj-search::placeholder { color: var(--muted); }
4249
+ .aj-search:focus { border-color: var(--accent); }
4250
+ .aj-list-wrap {
4251
+ flex: 1;
4252
+ overflow-y: auto;
4253
+ padding: 4px 22px 16px;
4254
+ }
4255
+ .aj-empty {
4256
+ font-size: 13px;
4257
+ color: var(--muted);
4258
+ padding: 14px 0;
4259
+ }
4260
+ .aj-shared-label { margin-top: 14px; }
4261
+
4219
4262
  /* #521 R9: hire-missing prompt in the new-project team step */
4220
4263
  .np-hire-note { grid-column: 1 / -1; font-size: 12px; color: var(--muted); line-height: 1.5; margin-top: 6px; }
4221
4264
  .emp-tile.locked-tile.hireable { opacity: 1; cursor: pointer; border-style: dashed; }
@@ -4249,6 +4292,46 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
4249
4292
  .rdu-extract-btn { align-self:flex-end; padding:7px 16px; background:var(--accent); color:#fff; border:none; border-radius:8px; font-size:12px; font-weight:600; cursor:pointer; }
4250
4293
  .rdu-extract-btn:disabled { opacity:.5; cursor:default; }
4251
4294
 
4295
+ /* ── Issue #671: Kanban Visualization ── */
4296
+ .kb-view-toggle { display:flex; gap:2px; margin-bottom:16px; }
4297
+ .kb-vtab { padding:6px 14px; font-size:12px; font-weight:600; color:var(--muted); background:var(--bg); border:1px solid var(--line); border-radius:8px; cursor:pointer; }
4298
+ .kb-vtab.on { background:var(--surface); color:var(--text); box-shadow:0 1px 3px rgba(0,0,0,.06); }
4299
+ .kb-vtab:hover:not(.on) { color:var(--text); }
4300
+
4301
+ #proj-kanban { padding:0 24px 32px; overflow-x:auto; }
4302
+ .kb-stats { font-size:12px; color:var(--muted); padding:0 0 14px; display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
4303
+ .kb-stats-waiting { color:var(--state-waiting); font-weight:600; }
4304
+ .kb-stats-running { color:var(--state-working); font-weight:600; }
4305
+ .kb-stats-blocked { color:var(--warn); font-weight:600; }
4306
+ .kb-stats-sep { color:var(--muted); }
4307
+
4308
+ .kb-board { display:flex; gap:12px; align-items:flex-start; min-width:max-content; }
4309
+ .kb-col { background:var(--bg); border:1px solid var(--line); border-radius:12px; padding:12px; width:260px; flex-shrink:0; }
4310
+ .kb-col.kb-col--empty { display:none; }
4311
+ .kb-col-header { display:flex; align-items:center; gap:6px; margin-bottom:10px; }
4312
+ .kb-col-title { font-size:12px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); flex:1; }
4313
+ .kb-col-count { font-size:11px; font-weight:700; color:var(--muted); background:var(--soft); padding:1px 7px; border-radius:8px; }
4314
+ .kb-done-filter { font-size:11px; color:var(--muted); background:transparent; border:none; cursor:pointer; padding:1px 2px; }
4315
+ .kb-cards { display:flex; flex-direction:column; gap:8px; }
4316
+
4317
+ .kb-card { background:var(--surface); border:1px solid var(--line); border-radius:10px; padding:12px 13px; box-shadow:var(--shadow); }
4318
+ .kb-job { font-size:13px; font-weight:600; color:var(--text); margin-bottom:8px; }
4319
+ .kb-meta { display:flex; align-items:center; gap:6px; flex-wrap:wrap; margin-bottom:6px; }
4320
+ .kb-proj-chip { font-size:10px; font-weight:700; color:var(--accent); background:var(--accent-soft); padding:2px 7px; border-radius:6px; }
4321
+ .kb-badge { font-size:10px; font-weight:700; padding:2px 7px; border-radius:5px; }
4322
+ .kb-waiting { color:var(--state-waiting); background:var(--state-waiting-soft); }
4323
+ .kb-stopped { color:var(--warn); background:var(--warn-soft); }
4324
+ .kb-av-row { margin-left:auto; display:flex; gap:4px; }
4325
+ .kb-age { font-size:11px; color:var(--muted); margin-bottom:8px; }
4326
+ .kb-actions { display:flex; gap:6px; flex-wrap:wrap; }
4327
+ .kb-chip { font-size:11px; font-weight:600; color:var(--muted); background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:3px 9px; cursor:pointer; }
4328
+ .kb-chip:hover { border-color:var(--accent); color:var(--accent); }
4329
+ .kb-chip--accent { color:var(--accent); background:var(--accent-soft); border-color:rgba(0,113,227,.25); }
4330
+ .kb-chip--accent:hover { background:var(--accent); color:#fff; }
4331
+ .kb-grid { display:flex; flex-wrap:wrap; gap:12px; padding:12px 24px 32px; }
4332
+ .kb-grid .kb-card { width:260px; flex-shrink:0; }
4333
+ .kb-empty-state { font-size:13px; color:var(--muted); padding:32px 24px; }
4334
+
4252
4335
  /* ── #512 R7: 'Done reviewing' button (replaces upload panel) ── */
4253
4336
  .rc-done-reviewing { margin-top:10px; padding:12px 14px; background:var(--accent-soft); border:1px solid rgba(0,113,227,.2); border-radius:10px; display:flex; flex-direction:column; gap:8px; }
4254
4337
  .rdr-label { font-size:12px; color:var(--muted); line-height:1.5; }