Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion client/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -16072,11 +16072,12 @@ class ClaudeOrchestrator {
const conflicts = conflictCount > 0 ? `⚠ conflicts:${conflictCount}` : '';
const reviewed = t?.record?.reviewedAt ? 'reviewed' : '';
const outcome = t?.record?.reviewOutcome ? `review:${t.record.reviewOutcome}` : '';
const assigned = t?.record?.assignedTo ? `assigned:${t.record.assignedTo}` : '';
const claim = t?.record?.claimedBy ? `claimed:${t.record.claimedBy}` : '';
const snoozedUntil = getSnoozeUntilMs(t?.id);
const snoozed = state.triageMode && snoozedUntil && Date.now() < snoozedUntil ? 'snoozed' : '';
const meta = [tier, risk].filter(Boolean).join(' • ');
const meta2 = [depTotal, depBlocked, conflicts, claim, reviewed, outcome, snoozed].filter(Boolean).join(' • ');
const meta2 = [depTotal, depBlocked, conflicts, assigned, claim, reviewed, outcome, snoozed].filter(Boolean).join(' • ');
const selected = state.selectedId === t.id;

const tags = [];
Expand Down Expand Up @@ -17066,6 +17067,8 @@ class ClaudeOrchestrator {
const promptChars = record.promptChars ?? '';
const claimedBy = record.claimedBy || '';
const claimedAt = record.claimedAt || '';
const assignedTo = record.assignedTo || '';
const assignedAt = record.assignedAt || '';
const ticketProvider = record.ticketProvider || '';
const ticketCardId = record.ticketCardId || '';
const ticketCardUrl = record.ticketCardUrl || '';
Expand Down Expand Up @@ -17119,6 +17122,18 @@ class ClaudeOrchestrator {
const nextAutoSnoozeMs = computeBackoffMs(snoozeCount + 1);
const nextAutoSnoozeLabel = formatBackoff(nextAutoSnoozeMs);

const identitySaved = Array.isArray(this.userSettings?.global?.ui?.identity?.saved)
? this.userSettings.global.ui.identity.saved
: [];
const identityOptions = Array.from(new Set([
...identitySaved.map((x) => String(x || '').trim()),
this.getIdentityClaimName()
].filter(Boolean)));
identityOptions.sort((a, b) => a.localeCompare(b));
const identityOptionsHtml = ['<option value="">(unassigned)</option>']
.concat(identityOptions.map((v) => `<option value="${escapeHtml(v)}" ${String(assignedTo || '') === String(v) ? 'selected' : ''}>${escapeHtml(v)}</option>`))
.join('');

detailEl.innerHTML = `
<div class="tasks-detail-header">
<div class="tasks-detail-title">
Expand Down Expand Up @@ -17237,6 +17252,21 @@ class ClaudeOrchestrator {
</div>
</div>

<div class="tasks-detail-block">
<div class="tasks-detail-block-title">Assign</div>
<div class="tasks-inline-row" style="gap:8px; flex-wrap:wrap;">
<span class="tasks-detail-meta" id="queue-assigned-meta">
${assignedTo ? `assignedTo: <code>${escapeHtml(assignedTo)}</code>${assignedAt ? ` • <span>${escapeHtml(assignedAt)}</span>` : ''}` : 'unassigned'}
</span>
<span style="flex:1"></span>
<select id="queue-assign" class="tasks-select tasks-select-inline" style="width:min(220px, 45vw);">
${identityOptionsHtml}
</select>
<button class="btn-secondary" id="queue-unassign" ${assignedTo ? '' : 'disabled'} title="Clear assignment">✕</button>
</div>
<div class="tasks-detail-meta">Uses Settings → Identity saved list.</div>
</div>

<div class="tasks-detail-block">
<div class="tasks-detail-block-title">Prompt Artifact</div>
<div class="tasks-inline-row">
Expand Down Expand Up @@ -17391,6 +17421,9 @@ class ClaudeOrchestrator {
const claimMetaEl = detailEl.querySelector('#queue-claim-meta');
const claimBtn = detailEl.querySelector('#queue-claim');
const releaseBtn = detailEl.querySelector('#queue-release');
const assignedMetaEl = detailEl.querySelector('#queue-assigned-meta');
const assignEl = detailEl.querySelector('#queue-assign');
const unassignBtn = detailEl.querySelector('#queue-unassign');
const ticketEl = detailEl.querySelector('#queue-ticket');
const ticketOpenBtn = detailEl.querySelector('#queue-ticket-open');
const doneEl = detailEl.querySelector('#queue-done');
Expand Down Expand Up @@ -17463,6 +17496,20 @@ class ClaudeOrchestrator {

applyClaimUI(record);

const applyAssignUI = (rec) => {
const by = String(rec?.assignedTo || '').trim();
const at = String(rec?.assignedAt || '').trim();
if (assignedMetaEl) {
assignedMetaEl.innerHTML = by
? `assignedTo: <code>${escapeHtml(by)}</code>${at ? ` • <span>${escapeHtml(at)}</span>` : ''}`
: 'unassigned';
}
if (unassignBtn) unassignBtn.disabled = !by;
if (assignEl) assignEl.value = by || '';
};

applyAssignUI(record);

const conflictTypeLabel = (type) => {
const t2 = String(type || '').trim().toLowerCase();
if (!t2) return 'conflict';
Expand Down Expand Up @@ -18025,6 +18072,33 @@ class ClaudeOrchestrator {
}
});

const saveAssignment = async (who) => {
try {
if (assignEl) assignEl.disabled = true;
if (unassignBtn) unassignBtn.disabled = true;
const patch = who ? { assignedTo: who } : { assignedTo: null };
const rec = await upsertRecord(t.id, patch);
updateTaskRecordInState(t.id, rec);
applyAssignUI(rec);
renderList();
} catch (e) {
this.showToast(String(e?.message || e), 'error');
} finally {
if (assignEl) assignEl.disabled = false;
if (unassignBtn) unassignBtn.disabled = !getTaskById(t.id)?.record?.assignedTo;
}
};

assignEl?.addEventListener('change', async () => {
const who = String(assignEl.value || '').trim();
await saveAssignment(who);
});

unassignBtn?.addEventListener('click', async (e) => {
e.preventDefault();
await saveAssignment('');
});

const saveNotes = async () => {
if (!notesEl) return;
try {
Expand Down
2 changes: 1 addition & 1 deletion server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3493,7 +3493,7 @@ app.put('/api/process/task-records/:id', express.json(), async (req, res) => {
const before = taskRecordService.get(id) || null;
const record = await taskRecordService.upsert(id, req.body || {});
try {
const keys = ['tier', 'risk', 'pFail', 'doneAt', 'reviewedAt', 'reviewOutcome', 'claimedAt', 'claimedBy'];
const keys = ['tier', 'risk', 'pFail', 'doneAt', 'reviewedAt', 'reviewOutcome', 'claimedAt', 'claimedBy', 'assignedAt', 'assignedTo'];
const changes = {};
for (const k of keys) {
const from = before?.[k] ?? null;
Expand Down
25 changes: 25 additions & 0 deletions server/taskRecordService.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,12 @@ const normalizeClaimedBy = (v) => {
return s.slice(0, 120);
};

const normalizeAssignedTo = (v) => {
const s = String(v || '').trim();
if (!s) return null;
return s.slice(0, 120);
};

const normalizeDependencies = (deps) => {
if (deps === null) return [];
if (!Array.isArray(deps)) return null;
Expand Down Expand Up @@ -560,6 +566,25 @@ class TaskRecordService {
else clear.add('claimedAt');
}

// Sling assignment (v1): assign a task record to an identity.
if (p.assignedTo !== undefined) {
if (p.assignedTo === null || p.assignedTo === '') {
clear.add('assignedTo');
clear.add('assignedAt');
} else {
const who = normalizeAssignedTo(p.assignedTo);
if (who !== null) {
next.assignedTo = who;
if (!next.assignedAt) next.assignedAt = new Date().toISOString();
}
}
}
if (p.assignedAt !== undefined) {
const dt = normalizeDateTime(p.assignedAt);
if (dt) next.assignedAt = dt;
else clear.add('assignedAt');
}

if (p.linked) {
next.linked = p.linked;
}
Expand Down
14 changes: 14 additions & 0 deletions tests/unit/taskRecordService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,20 @@ describe('TaskRecordService', () => {
expect(rec2.claimedAt).toBeUndefined();
});

test('upsert supports assignment fields', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'orchestrator-task-records-'));
const filePath = path.join(tmp, 'task-records.json');
const svc = new TaskRecordService({ filePath });

const rec = await svc.upsert('task:assign', { assignedTo: 'alice' });
expect(rec.assignedTo).toBe('alice');
expect(typeof rec.assignedAt).toBe('string');

const rec2 = await svc.upsert('task:assign', { assignedTo: null });
expect(rec2.assignedTo).toBeUndefined();
expect(rec2.assignedAt).toBeUndefined();
});

test('upsert supports review checklist fields', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'orchestrator-task-records-'));
const filePath = path.join(tmp, 'task-records.json');
Expand Down
Loading