fraim-framework 2.0.177 → 2.0.179
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/server.js +50 -1
- package/dist/src/cli/commands/add-provider.js +74 -61
- package/dist/src/cli/commands/add-surface.js +128 -0
- package/dist/src/cli/commands/login.js +5 -69
- package/dist/src/cli/commands/setup.js +27 -347
- package/dist/src/cli/distribution/marketplace-bundles.js +576 -0
- package/dist/src/cli/fraim.js +2 -0
- package/dist/src/cli/mcp/ide-formats.js +5 -3
- package/dist/src/cli/mcp/mcp-server-registry.js +10 -3
- package/dist/src/cli/providers/local-provider-registry.js +2 -3
- package/dist/src/cli/setup/auto-mcp-setup.js +9 -32
- package/dist/src/cli/setup/ide-detector.js +34 -14
- package/dist/src/config/persona-capability-bundles.js +17 -13
- package/dist/src/first-run/session-service.js +2 -2
- package/dist/src/local-mcp-server/stdio-server.js +28 -4
- package/dist/src/local-mcp-server/usage-collector.js +24 -0
- package/package.json +3 -2
- package/public/ai-hub/index.html +14 -2
- package/public/ai-hub/script.js +340 -66
- package/public/ai-hub/styles.css +83 -0
package/public/ai-hub/script.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
5172
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
6241
|
-
badge.
|
|
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);
|
|
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
|
-
|
|
8844
|
-
|
|
8845
|
-
|
|
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
|
-
|
|
9076
|
+
|
|
8855
9077
|
const hired = tfHiredPersonas();
|
|
8856
9078
|
const filterKey = employeeKey || null;
|
|
8857
|
-
const
|
|
8858
|
-
|
|
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
|
|
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
|
-
|
|
8868
|
-
|
|
8869
|
-
|
|
8870
|
-
|
|
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
|
-
|
|
8873
|
-
|
|
8874
|
-
|
|
8875
|
-
|
|
8876
|
-
|
|
8877
|
-
|
|
8878
|
-
|
|
8879
|
-
|
|
8880
|
-
|
|
8881
|
-
|
|
8882
|
-
|
|
8883
|
-
|
|
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
|
-
|
|
8894
|
-
|
|
8895
|
-
|
|
8896
|
-
|
|
8897
|
-
|
|
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');
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -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; }
|