diff --git a/server/index.js b/server/index.js
index b444dd5c..8d8ddea7 100644
--- a/server/index.js
+++ b/server/index.js
@@ -3645,7 +3645,8 @@ app.post('/api/webhooks/github', async (req, res) => {
reviewState: review.state || '',
reviewBody: review.body || '',
reviewUser: review.user?.login || '',
- url: pr.html_url || pr.url || ''
+ url: pr.html_url || pr.url || '',
+ reviewUrl: review.html_url || review.url || pr.html_url || pr.url || ''
});
return res.json({ ok: true, event, verified: sig.verified, action, result });
}
diff --git a/server/prReviewAutomationService.js b/server/prReviewAutomationService.js
index a3f7bf7d..faeb7101 100644
--- a/server/prReviewAutomationService.js
+++ b/server/prReviewAutomationService.js
@@ -38,6 +38,37 @@ const normalizeReviewerPostAction = (v) => {
return s === 'auto_fix' || s === 'auto-fix' ? 'auto_fix' : 'feedback';
};
+const normalizeDeliveryAction = (value, fallback = 'notify') => {
+ const raw = String(value || '').trim().toLowerCase();
+ if (!raw) return fallback;
+ if (raw === 'paste_and_notify' || raw === 'paste-and-notify') return 'paste_and_notify';
+ if (raw === 'paste' || raw === 'notify' || raw === 'none') return raw;
+ return fallback;
+};
+
+const normalizeAgentId = (value) => {
+ const raw = String(value || '').trim().toLowerCase();
+ return raw === 'codex' ? 'codex' : 'claude';
+};
+
+const summarizeReviewBody = (value) => {
+ const lines = String(value || '')
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean);
+
+ if (!lines.length) return '';
+
+ const picked = [];
+ for (const line of lines) {
+ if (picked.join('\n').length >= 1200) break;
+ picked.push(line);
+ if (picked.length >= 8) break;
+ }
+
+ return picked.join('\n').slice(0, 1200);
+};
+
const DEFAULT_CONFIG = {
enabled: false,
pollEnabled: true,
@@ -56,6 +87,11 @@ const DEFAULT_CONFIG = {
autoSpawnReviewer: true,
autoFeedbackToAuthor: true,
autoSpawnFixer: false,
+ notifyOnReviewerSpawn: true,
+ notifyOnReviewCompleted: true,
+ approvedDeliveryAction: 'notify',
+ commentedDeliveryAction: 'notify',
+ needsFixFeedbackAction: 'paste_and_notify',
maxConcurrentReviewers: 3,
repos: []
};
@@ -229,6 +265,79 @@ class PrReviewAutomationService {
return 'feedback';
}
+ _resolveOutcomeDeliveryAction(outcome, cfg = {}) {
+ const key = String(outcome || '').trim().toLowerCase();
+ if (key === 'approved') {
+ return normalizeDeliveryAction(cfg.approvedDeliveryAction, 'notify');
+ }
+ if (key === 'commented') {
+ return normalizeDeliveryAction(cfg.commentedDeliveryAction, 'notify');
+ }
+ return normalizeDeliveryAction(cfg.needsFixFeedbackAction, 'paste_and_notify');
+ }
+
+ _inferReviewerAgent(prId, reviewInfo = {}, cfg = {}) {
+ const active = this.activeReviewers.get(prId) || null;
+ const fromSession = String(active?.sessionId || '').trim().toLowerCase();
+ if (fromSession.endsWith('-codex')) return 'codex';
+ if (fromSession.endsWith('-claude')) return 'claude';
+
+ const record = this.taskRecordService?.get?.(prId) || null;
+ const stored = String(record?.reviewerAgent || '').trim().toLowerCase();
+ if (stored === 'codex' || stored === 'claude') return stored;
+
+ const latest = String(reviewInfo?.latestReviewAgent || '').trim().toLowerCase();
+ if (latest === 'codex' || latest === 'claude') return latest;
+
+ return normalizeAgentId(cfg.reviewerAgent || 'claude');
+ }
+
+ _buildReviewSnapshot(prId, reviewInfo = {}, outcome, cfg = {}) {
+ const reviewBody = String(reviewInfo?.reviewBody || '').trim();
+ const reviewSummary = summarizeReviewBody(reviewBody) || '(No detailed comments)';
+ return {
+ latestReviewBody: reviewBody || null,
+ latestReviewSummary: reviewSummary,
+ latestReviewOutcome: outcome || null,
+ latestReviewUser: String(reviewInfo?.reviewUser || '').trim() || null,
+ latestReviewUrl: String(reviewInfo?.reviewUrl || reviewInfo?.url || '').trim() || null,
+ latestReviewSubmittedAt: reviewInfo?.reviewSubmittedAt || new Date().toISOString(),
+ latestReviewAgent: this._inferReviewerAgent(prId, reviewInfo, cfg)
+ };
+ }
+
+ _buildReviewFeedbackMessage(prId, reviewInfo = {}, outcome) {
+ const { number } = this._getPrIdentityFromId(prId);
+ const normalizedOutcome = String(outcome || '').trim().toLowerCase();
+ const outcomeLabel = normalizedOutcome === 'approved'
+ ? 'APPROVED'
+ : normalizedOutcome === 'commented'
+ ? 'COMMENTED'
+ : 'CHANGES REQUESTED';
+ const reviewUrl = String(reviewInfo?.reviewUrl || reviewInfo?.url || '').trim();
+ const summary = String(reviewInfo?.reviewSummary || summarizeReviewBody(reviewInfo?.reviewBody || '') || '(No detailed comments)').trim();
+
+ return [
+ '',
+ '--- PR Review Update ---',
+ `PR #${reviewInfo.number || number || '?'} reviewed by ${reviewInfo.reviewUser || 'AI reviewer'}.`,
+ `Outcome: ${outcomeLabel}`,
+ reviewInfo.reviewAgent ? `Reviewer agent: ${String(reviewInfo.reviewAgent).trim()}` : '',
+ reviewUrl ? `GitHub: ${reviewUrl}` : '',
+ '',
+ 'Summary:',
+ summary,
+ '',
+ normalizedOutcome === 'needs_fix'
+ ? 'Please address the feedback and push updated commits.'
+ : normalizedOutcome === 'approved'
+ ? 'The PR review approved the current changes.'
+ : 'The reviewer left comments but did not block the PR.',
+ '--- End PR Review Update ---',
+ ''
+ ].filter(Boolean).join('\n');
+ }
+
_getPrIdentityFromId(prId) {
const raw = String(prId || '').trim();
const match = raw.match(/^pr:([^/]+)\/([^#]+)#(\d+)$/);
@@ -376,7 +485,7 @@ class PrReviewAutomationService {
return { ok: true, prId, spawned: false };
}
- async onReviewSubmitted({ owner, repo, number, reviewState, reviewBody, reviewUser, url }) {
+ async onReviewSubmitted({ owner, repo, number, reviewState, reviewBody, reviewUser, url, reviewUrl }) {
const cfg = this.getConfig();
if (!cfg.enabled || !cfg.webhookEnabled) {
return { ignored: true, reason: 'disabled' };
@@ -390,33 +499,60 @@ class PrReviewAutomationService {
: state === 'changes_requested' ? 'needs_fix'
: 'commented';
+ const snapshot = this._buildReviewSnapshot(prId, {
+ owner,
+ repo,
+ number,
+ url,
+ reviewBody,
+ reviewUser,
+ reviewSubmittedAt: new Date().toISOString(),
+ reviewUrl: reviewUrl || url
+ }, outcome, cfg);
+
this.taskRecordService?.upsert?.(prId, {
reviewed: true,
reviewedAt: new Date().toISOString(),
reviewOutcome: outcome,
- reviewEndedAt: new Date().toISOString()
+ reviewEndedAt: new Date().toISOString(),
+ ...snapshot
});
// Clean up active reviewer tracking
this.activeReviewers.delete(prId);
- if (outcome === 'needs_fix') {
- await this._routeNeedsFixReview(
- prId,
- {
- owner,
- repo,
- number,
- url,
- reviewBody,
- reviewUser,
- outcome
- },
- cfg
- );
- }
+ const reviewInfo = {
+ owner,
+ repo,
+ number,
+ url,
+ reviewBody,
+ reviewUser,
+ outcome,
+ reviewUrl: reviewUrl || url,
+ reviewSubmittedAt: snapshot.latestReviewSubmittedAt,
+ reviewSummary: snapshot.latestReviewSummary,
+ reviewAgent: snapshot.latestReviewAgent
+ };
- this._emitUpdate('review-completed', { prId, outcome, reviewUser });
+ const followUp = await this._handleReviewFollowUp(prId, reviewInfo, cfg, outcome);
+ this._emitUpdate('review-completed', {
+ prId,
+ outcome,
+ reviewUser,
+ recordPatch: {
+ reviewed: true,
+ reviewedAt: new Date().toISOString(),
+ reviewOutcome: outcome,
+ reviewEndedAt: new Date().toISOString(),
+ ...snapshot,
+ ...(followUp?.recordPatch || {})
+ },
+ reviewSummary: snapshot.latestReviewSummary,
+ reviewUrl: snapshot.latestReviewUrl,
+ pastedToSessionId: followUp?.sessionId || null,
+ deliveryAction: followUp?.deliveryAction || null
+ });
return { ok: true, prId, outcome };
}
@@ -523,6 +659,8 @@ class PrReviewAutomationService {
number,
title: prData?.title || '',
url: prData?.html_url || prData?.url || '',
+ reviewUrl: latestReview.html_url || prData?.html_url || prData?.url || '',
+ reviewSubmittedAt: submittedAt,
reviewState: latestReview.state,
reviewBody: latestReview.body || '',
reviewUser: latestReview.author?.login || ''
@@ -565,21 +703,69 @@ class PrReviewAutomationService {
: outcome === 'changes_requested' ? 'needs_fix'
: 'commented';
+ const snapshot = this._buildReviewSnapshot(item.prId, {
+ ...item,
+ reviewSubmittedAt: item.reviewSubmittedAt || new Date().toISOString(),
+ reviewUrl: item.reviewUrl || item.url || ''
+ }, mappedOutcome, cfg);
+
this.taskRecordService?.upsert?.(item.prId, {
reviewed: true,
reviewedAt: new Date().toISOString(),
reviewOutcome: mappedOutcome,
- reviewEndedAt: new Date().toISOString()
+ reviewEndedAt: new Date().toISOString(),
+ ...snapshot
});
this.activeReviewers.delete(item.prId);
logger.info('Review completed', { prId: item.prId, outcome: mappedOutcome });
- if (mappedOutcome === 'needs_fix') {
- await this._routeNeedsFixReview(item.prId, item, cfg);
+ const followUp = await this._handleReviewFollowUp(item.prId, {
+ ...item,
+ reviewSubmittedAt: snapshot.latestReviewSubmittedAt,
+ reviewUrl: snapshot.latestReviewUrl,
+ reviewSummary: snapshot.latestReviewSummary,
+ reviewAgent: snapshot.latestReviewAgent
+ }, cfg, mappedOutcome);
+
+ this._emitUpdate('review-completed', {
+ prId: item.prId,
+ outcome: mappedOutcome,
+ reviewUser: item.reviewUser,
+ recordPatch: {
+ reviewed: true,
+ reviewedAt: new Date().toISOString(),
+ reviewOutcome: mappedOutcome,
+ reviewEndedAt: new Date().toISOString(),
+ ...snapshot,
+ ...(followUp?.recordPatch || {})
+ },
+ reviewSummary: snapshot.latestReviewSummary,
+ reviewUrl: snapshot.latestReviewUrl,
+ pastedToSessionId: followUp?.sessionId || null,
+ deliveryAction: followUp?.deliveryAction || null
+ });
+ }
+
+ async _handleReviewFollowUp(prId, reviewInfo, cfg, outcome) {
+ if (outcome === 'needs_fix') {
+ return this._routeNeedsFixReview(prId, reviewInfo, cfg);
}
- this._emitUpdate('review-completed', { prId: item.prId, outcome: mappedOutcome });
+ const deliveryAction = this._resolveOutcomeDeliveryAction(outcome, cfg);
+ if (deliveryAction === 'none') {
+ return { deliveryAction };
+ }
+
+ return this._sendFeedbackToAuthor(prId, {
+ ...reviewInfo,
+ reviewSummary: reviewInfo?.reviewSummary || summarizeReviewBody(reviewInfo?.reviewBody || ''),
+ reviewAgent: reviewInfo?.reviewAgent || this._inferReviewerAgent(prId, reviewInfo, cfg)
+ }, cfg, {
+ outcome,
+ deliveryAction,
+ allowNotesFallback: false
+ });
}
// ---------------------------------------------------------------------------
@@ -691,6 +877,7 @@ class PrReviewAutomationService {
reviewerSpawnedAt: new Date().toISOString(),
reviewerWorktreeId: worktreeId,
reviewerSessionId: sessionId,
+ reviewerAgent: reviewerConfig.agentId,
reviewStartedAt: new Date().toISOString()
});
@@ -702,7 +889,19 @@ class PrReviewAutomationService {
mode: reviewerConfig.mode,
provider: reviewerConfig.provider
});
- this._emitUpdate('reviewer-spawned', { prId: pr.prId, sessionId, worktreeId });
+ this._emitUpdate('reviewer-spawned', {
+ prId: pr.prId,
+ sessionId,
+ worktreeId,
+ agentId: reviewerConfig.agentId,
+ recordPatch: {
+ reviewerSpawnedAt: new Date().toISOString(),
+ reviewerWorktreeId: worktreeId,
+ reviewerSessionId: sessionId,
+ reviewerAgent: reviewerConfig.agentId,
+ reviewStartedAt: new Date().toISOString()
+ }
+ });
return true;
} catch (e) {
logger.error('Error spawning reviewer', { prId: pr.prId, error: e.message, stack: e.stack });
@@ -788,6 +987,7 @@ class PrReviewAutomationService {
async _routeNeedsFixReview(prId, reviewInfo, cfg) {
const action = this._resolvePostReviewAction(prId, cfg);
const parsed = this._getPrIdentityFromId(prId);
+ const deliveryAction = this._resolveOutcomeDeliveryAction('needs_fix', cfg);
if (action === 'auto_fix') {
const record = this.taskRecordService?.get?.(prId) || {};
@@ -823,17 +1023,38 @@ class PrReviewAutomationService {
);
if (!spawnOk) {
- await this._sendFeedbackToAuthor(prId, resolved, cfg);
+ return this._sendFeedbackToAuthor(prId, {
+ ...resolved,
+ reviewSummary: summarizeReviewBody(resolved.reviewBody || ''),
+ reviewAgent: reviewInfo?.reviewAgent || this._inferReviewerAgent(prId, reviewInfo, cfg)
+ }, cfg, {
+ outcome: 'needs_fix',
+ deliveryAction,
+ allowNotesFallback: true
+ });
}
- return;
+ return {
+ deliveryAction: 'auto_fix',
+ recordPatch: {
+ fixerSpawnedAt: new Date().toISOString(),
+ fixerWorktreeId: spawnOk?.worktreeId || null
+ },
+ sessionId: spawnOk?.sessionId || null
+ };
}
- await this._sendFeedbackToAuthor(prId, {
+ return this._sendFeedbackToAuthor(prId, {
...reviewInfo,
owner: reviewInfo?.owner || '',
- repo: reviewInfo?.repo || ''
- }, cfg);
+ repo: reviewInfo?.repo || '',
+ reviewSummary: reviewInfo?.reviewSummary || summarizeReviewBody(reviewInfo?.reviewBody || ''),
+ reviewAgent: reviewInfo?.reviewAgent || this._inferReviewerAgent(prId, reviewInfo, cfg)
+ }, cfg, {
+ outcome: 'needs_fix',
+ deliveryAction,
+ allowNotesFallback: true
+ });
}
// ---------------------------------------------------------------------------
@@ -928,23 +1149,24 @@ class PrReviewAutomationService {
// Internal: send feedback to original author session
// ---------------------------------------------------------------------------
- async _sendFeedbackToAuthor(prId, reviewInfo, cfg) {
+ async _sendFeedbackToAuthor(prId, reviewInfo, cfg, options = {}) {
const record = this.taskRecordService?.get?.(prId);
if (!record) return;
- // Try to find the original author's session from the task record
- // Session IDs follow pattern: repoName-worktreeId-claude
+ const outcome = String(options?.outcome || reviewInfo?.outcome || 'needs_fix').trim().toLowerCase();
+ const deliveryAction = normalizeDeliveryAction(options?.deliveryAction, outcome === 'needs_fix' ? 'paste_and_notify' : 'notify');
+ const allowNotesFallback = options?.allowNotesFallback !== false;
+
const match = prId.match(/^pr:([^/]+)\/([^#]+)#(\d+)$/);
if (!match) return;
const [, , repo] = match;
- const reviewerConfig = this._resolveReviewerConfig(cfg);
- const preferredSuffix = `-${reviewerConfig.agentId}`;
+ const preferredAgentId = normalizeAgentId(reviewInfo?.reviewAgent || record?.reviewerAgent || cfg?.reviewerAgent || 'claude');
+ const preferredSuffix = `-${preferredAgentId}`;
const repoNeedle = String(repo || '').trim().toLowerCase();
- const targetWorktree = String(record.reviewerWorktreeId || '').trim().toLowerCase();
- const configuredSessionId = String(record.reviewerSessionId || '').trim();
+ const targetWorktree = String(record.reviewSourceWorktreeId || '').trim().toLowerCase();
+ const configuredSessionId = String(record.reviewSourceSessionId || '').trim();
- // Look through all active sessions for one working on the same repo/worktree
const entries = this.sessionManager?.getAllSessionEntries?.() || [];
let targetSession = null;
@@ -998,35 +1220,29 @@ class PrReviewAutomationService {
}
}
- const feedbackMsg = [
- `\n--- PR Review Feedback ---`,
- `PR #${reviewInfo.number} has been reviewed by ${reviewInfo.reviewUser || 'AI reviewer'}.`,
- `Outcome: CHANGES REQUESTED`,
- '',
- reviewInfo.reviewBody || '(No detailed comments)',
- '',
- 'Please address the feedback and push updated commits.',
- `--- End Review Feedback ---\n`
- ].join('\n');
+ const feedbackMsg = this._buildReviewFeedbackMessage(prId, reviewInfo, outcome);
+ const notesSummary = String(reviewInfo?.reviewSummary || summarizeReviewBody(reviewInfo?.reviewBody || '') || '(No detailed comments)').trim();
- if (targetSession) {
+ if (targetSession && (deliveryAction === 'paste' || deliveryAction === 'paste_and_notify')) {
logger.info('Sending review feedback to session', { prId, sessionId: targetSession });
this.sessionManager.writeToSession(targetSession, feedbackMsg);
- return;
+ const deliveredAt = new Date().toISOString();
+ this.taskRecordService?.upsert?.(prId, { latestReviewDeliveredAt: deliveredAt });
+ return {
+ deliveryAction,
+ sessionId: targetSession,
+ recordPatch: { latestReviewDeliveredAt: deliveredAt }
+ };
}
- // If no active session found and autoSpawnFixer is on, spawn a fixer
- if (cfg.autoSpawnFixer) {
- logger.info('Original session not found, would spawn fixer', { prId });
- this.taskRecordService?.upsert?.(prId, {
- notes: `Review feedback pending - original session not found. Fixer needed.`
- });
- } else {
- logger.info('No active session found for feedback, storing in task record', { prId });
+ if (allowNotesFallback) {
+ logger.info('No active session found for review delivery, storing in task record', { prId, deliveryAction });
this.taskRecordService?.upsert?.(prId, {
- notes: `Review: changes requested by ${reviewInfo.reviewUser || 'AI'}. Feedback: ${(reviewInfo.reviewBody || '').slice(0, 500)}`
+ notes: `Review (${outcome}) by ${reviewInfo.reviewUser || 'AI'}: ${notesSummary}`
});
}
+
+ return { deliveryAction, sessionId: null };
}
// ---------------------------------------------------------------------------
diff --git a/server/taskRecordService.js b/server/taskRecordService.js
index 2ff4be87..339fe025 100644
--- a/server/taskRecordService.js
+++ b/server/taskRecordService.js
@@ -140,6 +140,12 @@ const normalizeReviewerPostAction = (v) => {
return s === 'auto_fix' ? 'auto_fix' : 'feedback';
};
+const normalizeAgentId = (v) => {
+ const s = String(v || '').trim().toLowerCase();
+ if (!s) return null;
+ return s === 'codex' ? 'codex' : (s === 'claude' ? 'claude' : null);
+};
+
const normalizeDateTime = (v) => {
if (v === null || v === '') return null;
const dt = new Date(v);
@@ -451,6 +457,39 @@ class TaskRecordService {
}
}
+ if (p.reviewerSessionId !== undefined) {
+ if (p.reviewerSessionId === null || p.reviewerSessionId === '') {
+ clear.add('reviewerSessionId');
+ } else {
+ next.reviewerSessionId = String(p.reviewerSessionId || '').trim().slice(0, 240);
+ }
+ }
+
+ if (p.reviewerAgent !== undefined) {
+ if (p.reviewerAgent === null || p.reviewerAgent === '') {
+ clear.add('reviewerAgent');
+ } else {
+ const agentId = normalizeAgentId(p.reviewerAgent);
+ if (agentId !== null) next.reviewerAgent = agentId;
+ }
+ }
+
+ if (p.reviewSourceSessionId !== undefined) {
+ if (p.reviewSourceSessionId === null || p.reviewSourceSessionId === '') {
+ clear.add('reviewSourceSessionId');
+ } else {
+ next.reviewSourceSessionId = String(p.reviewSourceSessionId || '').trim().slice(0, 240);
+ }
+ }
+
+ if (p.reviewSourceWorktreeId !== undefined) {
+ if (p.reviewSourceWorktreeId === null || p.reviewSourceWorktreeId === '') {
+ clear.add('reviewSourceWorktreeId');
+ } else {
+ next.reviewSourceWorktreeId = String(p.reviewSourceWorktreeId || '').trim().slice(0, 120);
+ }
+ }
+
if (p.fixerSpawnedAt !== undefined) {
const dt = normalizeDateTime(p.fixerSpawnedAt);
if (dt) next.fixerSpawnedAt = dt;
@@ -493,6 +532,68 @@ class TaskRecordService {
}
}
+ if (p.latestReviewBody !== undefined) {
+ if (p.latestReviewBody === null || p.latestReviewBody === '') {
+ clear.add('latestReviewBody');
+ } else {
+ next.latestReviewBody = String(p.latestReviewBody || '').trim().slice(0, 20_000);
+ }
+ }
+
+ if (p.latestReviewSummary !== undefined) {
+ if (p.latestReviewSummary === null || p.latestReviewSummary === '') {
+ clear.add('latestReviewSummary');
+ } else {
+ next.latestReviewSummary = String(p.latestReviewSummary || '').trim().slice(0, 4_000);
+ }
+ }
+
+ if (p.latestReviewOutcome !== undefined) {
+ if (p.latestReviewOutcome === null || p.latestReviewOutcome === '') {
+ clear.add('latestReviewOutcome');
+ } else {
+ const outcome = normalizeReviewOutcome(p.latestReviewOutcome);
+ if (outcome !== null) next.latestReviewOutcome = outcome;
+ }
+ }
+
+ if (p.latestReviewUser !== undefined) {
+ if (p.latestReviewUser === null || p.latestReviewUser === '') {
+ clear.add('latestReviewUser');
+ } else {
+ next.latestReviewUser = String(p.latestReviewUser || '').trim().slice(0, 240);
+ }
+ }
+
+ if (p.latestReviewUrl !== undefined) {
+ if (p.latestReviewUrl === null || p.latestReviewUrl === '') {
+ clear.add('latestReviewUrl');
+ } else {
+ next.latestReviewUrl = String(p.latestReviewUrl || '').trim().slice(0, 600);
+ }
+ }
+
+ if (p.latestReviewSubmittedAt !== undefined) {
+ const dt = normalizeDateTime(p.latestReviewSubmittedAt);
+ if (dt) next.latestReviewSubmittedAt = dt;
+ else clear.add('latestReviewSubmittedAt');
+ }
+
+ if (p.latestReviewAgent !== undefined) {
+ if (p.latestReviewAgent === null || p.latestReviewAgent === '') {
+ clear.add('latestReviewAgent');
+ } else {
+ const agentId = normalizeAgentId(p.latestReviewAgent);
+ if (agentId !== null) next.latestReviewAgent = agentId;
+ }
+ }
+
+ if (p.latestReviewDeliveredAt !== undefined) {
+ const dt = normalizeDateTime(p.latestReviewDeliveredAt);
+ if (dt) next.latestReviewDeliveredAt = dt;
+ else clear.add('latestReviewDeliveredAt');
+ }
+
// Optional external ticket/task link (v1: Trello)
if (p.ticketProvider !== undefined) {
if (p.ticketProvider === null || p.ticketProvider === '') {
diff --git a/server/userSettingsService.js b/server/userSettingsService.js
index 1bf9c88f..b40bfc2a 100644
--- a/server/userSettingsService.js
+++ b/server/userSettingsService.js
@@ -374,6 +374,32 @@ class UserSettingsService {
closeIfNoDoneList: false,
pollMs: 60_000
}
+ },
+ prReview: {
+ enabled: false,
+ pollEnabled: true,
+ pollMs: 60_000,
+ webhookEnabled: true,
+ reviewerAgent: 'claude',
+ reviewerMode: 'fresh',
+ reviewerProvider: 'anthropic',
+ reviewerClaudeModel: '',
+ reviewerSkipPermissions: true,
+ reviewerCodexModel: '',
+ reviewerCodexReasoning: '',
+ reviewerCodexVerbosity: '',
+ reviewerCodexFlags: ['yolo'],
+ reviewerTier: 3,
+ autoSpawnReviewer: true,
+ autoFeedbackToAuthor: true,
+ autoSpawnFixer: false,
+ notifyOnReviewerSpawn: true,
+ notifyOnReviewCompleted: true,
+ approvedDeliveryAction: 'notify',
+ commentedDeliveryAction: 'notify',
+ needsFixFeedbackAction: 'paste_and_notify',
+ maxConcurrentReviewers: 3,
+ repos: []
}
},
kanban: {
@@ -812,6 +838,10 @@ class UserSettingsService {
...((defaultsAutomations.trello || {}).onPrMerged || {}),
...(((next.trello || {}).onPrMerged) || {})
}
+ },
+ prReview: {
+ ...(defaultsAutomations.prReview || {}),
+ ...(next.prReview || {})
}
};
}
diff --git a/tests/unit/prReviewAutomationService.test.js b/tests/unit/prReviewAutomationService.test.js
index be9d73dd..f3b4c67a 100644
--- a/tests/unit/prReviewAutomationService.test.js
+++ b/tests/unit/prReviewAutomationService.test.js
@@ -197,6 +197,61 @@ describe('PrReviewAutomationService reviewer spawning', () => {
})
);
});
+
+ test('stores review snapshot and pastes needs-fix feedback back to the source session', async () => {
+ const { service, sessionManager, taskRecordService } = makeAutomationService();
+ service.userSettingsService = {
+ getAllSettings: () => ({
+ global: {
+ ui: {
+ tasks: {
+ automations: {
+ prReview: {
+ enabled: true,
+ webhookEnabled: true,
+ needsFixFeedbackAction: 'paste_and_notify'
+ }
+ }
+ }
+ }
+ }
+ })
+ };
+ taskRecordService.get.mockReturnValue({
+ reviewSourceSessionId: 'demo-work2-claude',
+ reviewSourceWorktreeId: 'work2',
+ reviewerPostAction: 'feedback'
+ });
+ sessionManager.getAllSessionEntries.mockReturnValue([
+ ['demo-work2-claude', { status: 'waiting' }]
+ ]);
+
+ const result = await service.onReviewSubmitted({
+ owner: 'acme',
+ repo: 'demo',
+ number: 77,
+ reviewState: 'changes_requested',
+ reviewBody: 'Fix the failing edge case and add a regression test.',
+ reviewUser: 'review-bot',
+ url: 'https://github.com/acme/demo/pull/77',
+ reviewUrl: 'https://github.com/acme/demo/pull/77#pullrequestreview-1'
+ });
+
+ expect(result).toEqual(expect.objectContaining({ ok: true, outcome: 'needs_fix' }));
+ expect(taskRecordService.upsert).toHaveBeenCalledWith(
+ 'pr:acme/demo#77',
+ expect.objectContaining({
+ latestReviewOutcome: 'needs_fix',
+ latestReviewUser: 'review-bot',
+ latestReviewSummary: expect.stringContaining('Fix the failing edge case'),
+ latestReviewUrl: 'https://github.com/acme/demo/pull/77#pullrequestreview-1'
+ })
+ );
+ expect(sessionManager.writeToSession).toHaveBeenCalledWith(
+ 'demo-work2-claude',
+ expect.stringContaining('CHANGES REQUESTED')
+ );
+ });
});
describe('SessionManager.buildClaudeCommand', () => {
diff --git a/tests/unit/taskRecordService.test.js b/tests/unit/taskRecordService.test.js
index faa45071..64fdcb63 100644
--- a/tests/unit/taskRecordService.test.js
+++ b/tests/unit/taskRecordService.test.js
@@ -57,10 +57,22 @@ describe('TaskRecordService', () => {
promptChars: 123,
reviewerSpawnedAt: '2026-01-25T00:00:11Z',
reviewerWorktreeId: 'work9',
+ reviewerSessionId: 'demo-work9-claude',
+ reviewerAgent: 'CLAUDE',
+ reviewSourceSessionId: 'demo-work1-claude',
+ reviewSourceWorktreeId: 'work1',
fixerSpawnedAt: '2026-01-25T00:00:12Z',
fixerWorktreeId: 'work10',
recheckSpawnedAt: '2026-01-25T00:00:13Z',
- recheckWorktreeId: 'work11'
+ recheckWorktreeId: 'work11',
+ latestReviewSummary: 'Fix the edge case.',
+ latestReviewBody: 'Fix the edge case. Add a regression test.',
+ latestReviewOutcome: 'NEEDS_FIX',
+ latestReviewUser: 'review-bot',
+ latestReviewUrl: 'https://github.com/acme/demo/pull/1#pullrequestreview-1',
+ latestReviewSubmittedAt: '2026-01-25T00:00:14Z',
+ latestReviewAgent: 'CODEX',
+ latestReviewDeliveredAt: '2026-01-25T00:00:15Z'
});
expect(rec.reviewStartedAt).toBe('2026-01-25T00:00:00.000Z');
@@ -69,10 +81,22 @@ describe('TaskRecordService', () => {
expect(rec.promptChars).toBe(123);
expect(rec.reviewerSpawnedAt).toBe('2026-01-25T00:00:11.000Z');
expect(rec.reviewerWorktreeId).toBe('work9');
+ expect(rec.reviewerSessionId).toBe('demo-work9-claude');
+ expect(rec.reviewerAgent).toBe('claude');
+ expect(rec.reviewSourceSessionId).toBe('demo-work1-claude');
+ expect(rec.reviewSourceWorktreeId).toBe('work1');
expect(rec.fixerSpawnedAt).toBe('2026-01-25T00:00:12.000Z');
expect(rec.fixerWorktreeId).toBe('work10');
expect(rec.recheckSpawnedAt).toBe('2026-01-25T00:00:13.000Z');
expect(rec.recheckWorktreeId).toBe('work11');
+ expect(rec.latestReviewSummary).toBe('Fix the edge case.');
+ expect(rec.latestReviewBody).toBe('Fix the edge case. Add a regression test.');
+ expect(rec.latestReviewOutcome).toBe('needs_fix');
+ expect(rec.latestReviewUser).toBe('review-bot');
+ expect(rec.latestReviewUrl).toBe('https://github.com/acme/demo/pull/1#pullrequestreview-1');
+ expect(rec.latestReviewSubmittedAt).toBe('2026-01-25T00:00:14.000Z');
+ expect(rec.latestReviewAgent).toBe('codex');
+ expect(rec.latestReviewDeliveredAt).toBe('2026-01-25T00:00:15.000Z');
});
test('upsert supports prompt repo location fields', async () => {
diff --git a/tests/unit/userSettingsDefaults.test.js b/tests/unit/userSettingsDefaults.test.js
index e2b29f2b..a550b891 100644
--- a/tests/unit/userSettingsDefaults.test.js
+++ b/tests/unit/userSettingsDefaults.test.js
@@ -114,6 +114,18 @@ describe('UserSettingsService defaults', () => {
expect(typeof pager.doneCheck.enabled).toBe('boolean');
});
+ test('includes PR review automation defaults', () => {
+ const defaults = UserSettingsService.prototype.getDefaultSettings.call({});
+ const prReview = defaults?.global?.ui?.tasks?.automations?.prReview;
+ expect(prReview).toBeTruthy();
+ expect(prReview.reviewerAgent).toBe('claude');
+ expect(prReview.notifyOnReviewerSpawn).toBe(true);
+ expect(prReview.notifyOnReviewCompleted).toBe(true);
+ expect(prReview.approvedDeliveryAction).toBe('notify');
+ expect(prReview.commentedDeliveryAction).toBe('notify');
+ expect(prReview.needsFixFeedbackAction).toBe('paste_and_notify');
+ });
+
test('mergeSettings deep-merges ui.tasks without dropping defaults', () => {
const defaults = UserSettingsService.prototype.getDefaultSettings.call({});
const merged = UserSettingsService.prototype.mergeSettings.call({}, defaults, {
diff --git a/user-settings.default.json b/user-settings.default.json
index 8bf9d8e0..d2911f9f 100644
--- a/user-settings.default.json
+++ b/user-settings.default.json
@@ -279,6 +279,11 @@
"autoSpawnReviewer": true,
"autoFeedbackToAuthor": true,
"autoSpawnFixer": false,
+ "notifyOnReviewerSpawn": true,
+ "notifyOnReviewCompleted": true,
+ "approvedDeliveryAction": "notify",
+ "commentedDeliveryAction": "notify",
+ "needsFixFeedbackAction": "paste_and_notify",
"maxConcurrentReviewers": 3,
"repos": []
}
From 9a4113caa2a18a8cec3beaf633618f72fb19c9f0 Mon Sep 17 00:00:00 2001
From: web3dev1337 <160291380+web3dev1337@users.noreply.github.com>
Date: Tue, 10 Mar 2026 16:28:55 +1100
Subject: [PATCH 07/14] feat: auto-link detected PRs to source sessions
---
CODEBASE_DOCUMENTATION.md | 3 +++
client/app.js | 44 +++++++++++++++++++++++++++++++++++++++
2 files changed, 47 insertions(+)
diff --git a/CODEBASE_DOCUMENTATION.md b/CODEBASE_DOCUMENTATION.md
index 22a6e7cb..e560c557 100644
--- a/CODEBASE_DOCUMENTATION.md
+++ b/CODEBASE_DOCUMENTATION.md
@@ -334,6 +334,9 @@ Queue detail actions now include saved-review affordances:
- `Open latest review`: opens the saved review URL (or PR URL fallback)
- `Paste to agent`: writes the saved review summary/body back into the source agent terminal via the existing terminal-input path
+Source-session linking is no longer Queue-only:
+- whenever any non-server terminal picks up an `existingPR` link through branch detection/session restore, the client now upserts `reviewSourceSessionId` / `reviewSourceWorktreeId` on the PR task record so background review completion can route back to that terminal automatically
+
### Workspace Templates & Scripts
```
templates/launch-settings/ - Workspace configuration templates
diff --git a/client/app.js b/client/app.js
index 0d4709e2..d790f207 100644
--- a/client/app.js
+++ b/client/app.js
@@ -1208,6 +1208,7 @@ class ClaudeOrchestrator {
const links = this.githubLinks.get(sessionId) || {};
links.pr = sessionState.existingPR;
this.githubLinks.set(sessionId, links);
+ this.maybeLinkPrTaskToSession(sessionId, sessionState.existingPR, { sessionOverride: this.sessions.get(sessionId) }).catch(() => {});
}
// Mark new sessions as active (active-only filter should treat background work as active too).
@@ -3578,6 +3579,47 @@ class ClaudeOrchestrator {
return `pr:${owner}/${repo}#${prNum}`;
}
+ async maybeLinkPrTaskToSession(sessionId, prUrl, { sessionOverride = null } = {}) {
+ const sid = String(sessionId || '').trim();
+ const url = String(prUrl || '').trim();
+ if (!sid || !url) return null;
+ if (sid.endsWith('-server')) return null;
+
+ const prTaskId = this.getPRTaskIdFromUrl(url);
+ if (!prTaskId) return null;
+
+ const session = sessionOverride || this.sessions.get(sid) || null;
+ const worktreePath = this.resolveWorktreePathForSession(sid, session);
+ const worktreeId = String(session?.worktreeId || this.extractWorktreeLabel(worktreePath) || '').trim();
+
+ const existing = this.taskRecords.get(prTaskId) || {};
+ if (
+ String(existing?.reviewSourceSessionId || '').trim() === sid
+ && String(existing?.reviewSourceWorktreeId || '').trim() === worktreeId
+ ) {
+ return existing;
+ }
+
+ const patch = {
+ reviewSourceSessionId: sid,
+ reviewSourceWorktreeId: worktreeId || null
+ };
+
+ try {
+ const rec = await this.upsertTaskRecord(prTaskId, patch);
+ if (rec) {
+ const merged = { ...(existing && typeof existing === 'object' ? existing : {}), ...rec };
+ this.taskRecords.set(prTaskId, merged);
+ this.queuePanelApi?.handleAutomationEvent?.({ prId: prTaskId, recordPatch: rec });
+ return merged;
+ }
+ } catch (error) {
+ console.warn('Failed to link PR task to source session:', { sessionId: sid, prUrl: url, error: error?.message || error });
+ }
+
+ return null;
+ }
+
getTierForSession(sessionId) {
const session = this.sessions.get(sessionId);
const prUrl = this.githubLinks.get(sessionId)?.pr || null;
@@ -3669,6 +3711,7 @@ class ClaudeOrchestrator {
links.pr = state.existingPR;
this.githubLinks.set(sessionId, links);
console.log('Loaded existing PR for session:', sessionId, state.existingPR);
+ this.maybeLinkPrTaskToSession(sessionId, state.existingPR, { sessionOverride: sessionData }).catch(() => {});
}
// For mixed-repo workspaces, set terminals as active immediately so they show by default
@@ -6074,6 +6117,7 @@ class ClaudeOrchestrator {
this.githubLinks.set(sessionId, links);
console.log('Automatically detected existing PR:', existingPR);
this.maybeSchedulePrIntentRefresh(sessionId, existingPR);
+ this.maybeLinkPrTaskToSession(sessionId, existingPR, { sessionOverride: session }).catch(() => {});
}
}
From 55e1551a9cae87a9fba4ca0e709d81cc6b346c00 Mon Sep 17 00:00:00 2001
From: web3dev1337 <160291380+web3dev1337@users.noreply.github.com>
Date: Tue, 10 Mar 2026 16:36:19 +1100
Subject: [PATCH 08/14] feat: surface saved PR reviews in terminal headers
---
CODEBASE_DOCUMENTATION.md | 1 +
client/app.js | 102 ++++++++++++++++++++++++++++++++++++++
client/styles.css | 19 +++++++
3 files changed, 122 insertions(+)
diff --git a/CODEBASE_DOCUMENTATION.md b/CODEBASE_DOCUMENTATION.md
index e560c557..1ed6d5b1 100644
--- a/CODEBASE_DOCUMENTATION.md
+++ b/CODEBASE_DOCUMENTATION.md
@@ -333,6 +333,7 @@ When a reviewer completes, the result is routed by outcome:
Queue detail actions now include saved-review affordances:
- `Open latest review`: opens the saved review URL (or PR URL fallback)
- `Paste to agent`: writes the saved review summary/body back into the source agent terminal via the existing terminal-input path
+- agent terminal headers also surface `⏳` while a review is running plus `📝` / `↩` buttons once a saved review snapshot exists for that session's linked PR
Source-session linking is no longer Queue-only:
- whenever any non-server terminal picks up an `existingPR` link through branch detection/session restore, the client now upserts `reviewSourceSessionId` / `reviewSourceWorktreeId` on the PR task record so background review completion can route back to that terminal automatically
diff --git a/client/app.js b/client/app.js
index d790f207..b5f8ffea 100644
--- a/client/app.js
+++ b/client/app.js
@@ -3611,6 +3611,7 @@ class ClaudeOrchestrator {
const merged = { ...(existing && typeof existing === 'object' ? existing : {}), ...rec };
this.taskRecords.set(prTaskId, merged);
this.queuePanelApi?.handleAutomationEvent?.({ prId: prTaskId, recordPatch: rec });
+ this.updateTerminalControlsForPrTask(prTaskId);
return merged;
}
} catch (error) {
@@ -3620,6 +3621,105 @@ class ClaudeOrchestrator {
return null;
}
+ getPrReviewMetaForSession(sessionId) {
+ const sid = String(sessionId || '').trim();
+ if (!sid) return null;
+
+ const prUrl = String(this.githubLinks.get(sid)?.pr || '').trim();
+ const prTaskId = prUrl ? this.getPRTaskIdFromUrl(prUrl) : null;
+ if (!prTaskId) return null;
+
+ const record = this.taskRecords.get(prTaskId) || {};
+ const latestReviewSummary = String(record?.latestReviewSummary || '').trim();
+ const latestReviewBody = String(record?.latestReviewBody || '').trim();
+ const latestReviewOutcome = String(record?.latestReviewOutcome || '').trim().toLowerCase();
+ const reviewPending = !!record?.reviewerSpawnedAt && !record?.reviewedAt;
+
+ return {
+ prTaskId,
+ prUrl,
+ record,
+ latestReviewSummary,
+ latestReviewBody,
+ latestReviewOutcome,
+ hasLatestReview: !!(latestReviewSummary || latestReviewBody),
+ reviewPending
+ };
+ }
+
+ getLinkedSessionIdsForPrTask(prTaskId) {
+ const taskId = String(prTaskId || '').trim();
+ if (!taskId) return [];
+
+ const linked = new Set();
+ for (const [sessionId, links] of this.githubLinks.entries()) {
+ const prUrl = String(links?.pr || '').trim();
+ if (!prUrl) continue;
+ if (this.getPRTaskIdFromUrl(prUrl) === taskId) linked.add(sessionId);
+ }
+
+ const record = this.taskRecords.get(taskId) || {};
+ const sourceSessionId = String(record?.reviewSourceSessionId || '').trim();
+ if (sourceSessionId) linked.add(sourceSessionId);
+
+ return Array.from(linked);
+ }
+
+ updateTerminalControlsForPrTask(prTaskId) {
+ this.getLinkedSessionIdsForPrTask(prTaskId).forEach((sessionId) => {
+ this.updateTerminalControls(sessionId);
+ });
+ }
+
+ openLatestReviewForSession(sessionId) {
+ const meta = this.getPrReviewMetaForSession(sessionId);
+ if (!meta?.prTaskId) {
+ this.showToast?.('No linked PR review found for this terminal', 'warning');
+ return false;
+ }
+ return this.openLatestReviewForTask({ id: meta.prTaskId, url: meta.prUrl, record: meta.record });
+ }
+
+ pasteLatestReviewToSessionFromHeader(sessionId) {
+ const sid = String(sessionId || '').trim();
+ const meta = this.getPrReviewMetaForSession(sid);
+ if (!sid || !meta?.prTaskId) {
+ this.showToast?.('No linked PR review found for this terminal', 'warning');
+ return false;
+ }
+
+ const payload = this.buildLatestReviewMessageForTask({ id: meta.prTaskId, url: meta.prUrl, record: meta.record });
+ if (!payload) {
+ this.showToast?.('No saved review summary is available yet', 'warning');
+ return false;
+ }
+
+ this.sendTerminalInput(sid, `${payload}\n`);
+ this.showToast?.(`Pasted saved review into ${sid}`, 'success');
+ return true;
+ }
+
+ getPrReviewButtons(sessionId) {
+ const sid = String(sessionId || '').trim();
+ const meta = this.getPrReviewMetaForSession(sid);
+ if (!meta) return '';
+
+ const parts = [];
+ if (meta.reviewPending) {
+ parts.push(`
`);
+ }
+
+ if (meta.hasLatestReview) {
+ const outcome = meta.latestReviewOutcome || 'review';
+ const openTitle = `Open saved ${outcome} review`;
+ const pasteTitle = `Paste saved ${outcome} review back into this terminal`;
+ parts.push(`
`);
+ parts.push(`
`);
+ }
+
+ return parts.join('');
+ }
+
getTierForSession(sessionId) {
const session = this.sessions.get(sessionId);
const prUrl = this.githubLinks.get(sessionId)?.pr || null;
@@ -6397,6 +6497,7 @@ class ClaudeOrchestrator {
}
}
+ buttons += this.getPrReviewButtons(sessionId);
return buttons;
}
@@ -6790,6 +6891,7 @@ class ClaudeOrchestrator {
}
this.queuePanelApi?.handleAutomationEvent?.(payload);
+ if (prId) this.updateTerminalControlsForPrTask(prId);
const cfg = this.userSettings?.global?.ui?.tasks?.automations?.prReview || {};
const label = prId || 'PR review';
diff --git a/client/styles.css b/client/styles.css
index 62b1bf22..210b7e81 100644
--- a/client/styles.css
+++ b/client/styles.css
@@ -6037,6 +6037,25 @@ body.dependency-onboarding-active #dependency-setup-modal {
border-color: var(--accent-primary-hover);
}
+.control-btn.pr-review-status-btn.pending {
+ background: color-mix(in srgb, var(--accent-warning) 18%, var(--bg-tertiary));
+ border-color: color-mix(in srgb, var(--accent-warning) 50%, var(--border-primary));
+ color: var(--accent-warning);
+}
+
+.control-btn.pr-review-status-btn.ready,
+.control-btn.pr-review-paste-btn {
+ background: color-mix(in srgb, var(--accent-primary) 14%, var(--bg-tertiary));
+ border-color: color-mix(in srgb, var(--accent-primary) 45%, var(--border-primary));
+ color: var(--accent-primary);
+}
+
+.control-btn.pr-review-status-btn.ready:hover,
+.control-btn.pr-review-paste-btn:hover {
+ background: color-mix(in srgb, var(--accent-primary) 22%, var(--bg-tertiary));
+ border-color: var(--accent-primary);
+}
+
.terminal-controls {
position: relative;
}
From 649928e065e81fcd0ce641941246f1bf76ba462d Mon Sep 17 00:00:00 2001
From: AnrokX <192667251+AnrokX@users.noreply.github.com>
Date: Tue, 10 Mar 2026 12:57:13 -0600
Subject: [PATCH 09/14] fix: streamline queue pr review startup
---
client/app.js | 77 +++++++++++++------
server/processProjectDashboardService.js | 27 +++++--
server/processTaskService.js | 23 +++++-
server/taskRecordService.js | 96 ++++++++++++++++++------
tests/unit/processTaskService.test.js | 10 ++-
tests/unit/taskRecordService.test.js | 18 +++++
6 files changed, 194 insertions(+), 57 deletions(-)
diff --git a/client/app.js b/client/app.js
index b5f8ffea..161735dc 100644
--- a/client/app.js
+++ b/client/app.js
@@ -935,8 +935,20 @@ class ClaudeOrchestrator {
});
document.getElementById('workflow-review')?.addEventListener('click', () => {
this.setWorkflowMode('review');
- // Batch review defaults (Tier 3, unreviewed, console+diff ready).
- this.queuePanelPreset = { reviewTier: 3, unreviewedOnly: true, autoOpenDiff: true, autoConsole: true, autoAdvance: true, reviewActive: true };
+ // Open a simple PR-first picker; power-user review lanes still live under Flows.
+ this.queuePanelPreset = {
+ mode: 'mine',
+ kindFilter: 'pr',
+ reviewTier: 'all',
+ tierSet: null,
+ unreviewedOnly: false,
+ blockedOnly: false,
+ autoOpenDiff: false,
+ autoConsole: false,
+ autoAdvance: false,
+ reviewActive: false,
+ prioritizeActive: true
+ };
this.showQueuePanel();
});
document.getElementById('workflow-background')?.addEventListener('click', () => {
@@ -3579,6 +3591,27 @@ class ClaudeOrchestrator {
return `pr:${owner}/${repo}#${prNum}`;
}
+ getRepositorySlugForPRTask(task) {
+ const t = task && typeof task === 'object' ? task : {};
+ const direct = String(t.repository || '').trim();
+ if (direct) return direct;
+
+ const parse = (value) => {
+ const raw = String(value || '').trim();
+ if (!raw) return '';
+
+ const prTaskMatch = raw.match(/^pr:([^/]+\/[^#]+)#\d+$/i);
+ if (prTaskMatch) return prTaskMatch[1];
+
+ const urlMatch = raw.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/\d+/i);
+ if (urlMatch) return `${urlMatch[1]}/${urlMatch[2]}`;
+
+ return '';
+ };
+
+ return parse(t.url) || parse(t.id) || '';
+ }
+
async maybeLinkPrTaskToSession(sessionId, prUrl, { sessionOverride = null } = {}) {
const sid = String(sessionId || '').trim();
const url = String(prUrl || '').trim();
@@ -28722,6 +28755,22 @@ class ClaudeOrchestrator {
this.pasteLatestReviewToTaskSession(getTaskById(t.id) || t);
});
+ reviewPostActionEl?.addEventListener('change', async () => {
+ try {
+ const value = String(reviewPostActionEl.value || '').trim().toLowerCase();
+ reviewPostActionEl.disabled = true;
+ const patch = { reviewerPostAction: value || 'feedback' };
+ const rec = await upsertRecord(t.id, patch);
+ updateTaskRecordInState(t.id, rec);
+ await maybeAutoSpawnFixer({ ...(t || {}), record: rec });
+ renderDetail(getTaskById(t.id));
+ } catch (e) {
+ this.showToast(String(e?.message || e), 'error');
+ } finally {
+ if (reviewPostActionEl) reviewPostActionEl.disabled = false;
+ }
+ });
+
const runGitHubReview = async ({ action, body } = {}) => {
const res = await fetch('/api/prs/review', {
method: 'POST',
@@ -29233,22 +29282,6 @@ class ClaudeOrchestrator {
pairingBtn.addEventListener('click', () => {
showPairingModal().catch((e) => this.showToast(String(e?.message || e), 'error'));
});
-
- reviewPostActionEl?.addEventListener('change', async () => {
- try {
- const value = String(reviewPostActionEl.value || '').trim().toLowerCase();
- reviewPostActionEl.disabled = true;
- const patch = { reviewerPostAction: value || 'feedback' };
- const rec = await upsertRecord(t.id, patch);
- updateTaskRecordInState(t.id, rec);
- await maybeAutoSpawnFixer({ ...(t || {}), record: rec });
- renderDetail(getTaskById(t.id));
- } catch (e) {
- this.showToast(String(e?.message || e), 'error');
- } finally {
- if (reviewPostActionEl) reviewPostActionEl.disabled = false;
- }
- });
}
mineBtn.addEventListener('click', async () => {
@@ -32924,12 +32957,12 @@ class ClaudeOrchestrator {
return { agentId: 'claude', mode: m, flags };
}
- async spawnReviewAgentForPRTask(prTask, { tier = 3, agentId = 'claude', mode = 'fresh', yolo = true, worktreeId = null } = {}) {
+ async spawnReviewAgentForPRTask(prTask, { tier = 3, agentId = 'claude', mode = 'fresh', yolo = true, worktreeId = null } = {}) {
const t = prTask || {};
if (t.kind !== 'pr') return;
const url = String(t.url || '').trim();
- const repoSlug = String(t.repository || '').trim();
+ const repoSlug = this.getRepositorySlugForPRTask(t);
if (!repoSlug) {
this.showToast('PR task is missing repository slug', 'error');
return;
@@ -33037,7 +33070,7 @@ class ClaudeOrchestrator {
if (t.kind !== 'pr') return null;
const url = String(t.url || '').trim();
- const repoSlug = String(t.repository || '').trim();
+ const repoSlug = this.getRepositorySlugForPRTask(t);
if (!repoSlug) {
this.showToast('PR task is missing repository slug', 'error');
return null;
@@ -33151,7 +33184,7 @@ class ClaudeOrchestrator {
if (t.kind !== 'pr') return null;
const url = String(t.url || '').trim();
- const repoSlug = String(t.repository || '').trim();
+ const repoSlug = this.getRepositorySlugForPRTask(t);
if (!repoSlug) {
this.showToast('PR task is missing repository slug', 'error');
return null;
diff --git a/server/processProjectDashboardService.js b/server/processProjectDashboardService.js
index 38a08708..7e045de5 100644
--- a/server/processProjectDashboardService.js
+++ b/server/processProjectDashboardService.js
@@ -28,6 +28,24 @@ const riskRank = (risk) => {
return 0;
};
+const extractRepoSlugFromPullRequest = (pr) => {
+ const nameWithOwner = String(pr?.repository?.nameWithOwner || '').trim();
+ if (nameWithOwner) return nameWithOwner;
+
+ const owner = pr?.repository?.owner?.login || pr?.repository?.owner?.name || null;
+ const name = pr?.repository?.name || null;
+ if (owner && name) return `${owner}/${name}`;
+
+ const repoSlug = String(pr?.repository || '').trim();
+ if (/^[^/]+\/[^/]+$/.test(repoSlug)) return repoSlug;
+
+ const url = String(pr?.url || '').trim();
+ const match = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/\d+/i);
+ if (match) return `${match[1]}/${match[2]}`;
+
+ return null;
+};
+
class ProcessProjectDashboardService {
constructor({ pullRequestService, taskRecordService } = {}) {
this.pullRequestService = pullRequestService;
@@ -64,9 +82,7 @@ class ProcessProjectDashboardService {
const recordsById = new Map();
if (this.taskRecordService?.get) {
for (const pr of prs) {
- const owner = pr?.repository?.owner?.login || pr?.repository?.owner?.name || null;
- const name = pr?.repository?.name || null;
- const repoSlug = owner && name ? `${owner}/${name}` : null;
+ const repoSlug = extractRepoSlugFromPullRequest(pr);
const id = repoSlug && pr?.number ? `pr:${repoSlug}#${pr.number}` : null;
if (!id) continue;
// eslint-disable-next-line no-await-in-loop
@@ -78,9 +94,7 @@ class ProcessProjectDashboardService {
const byRepo = new Map();
for (const pr of prs) {
- const owner = pr?.repository?.owner?.login || pr?.repository?.owner?.name || null;
- const name = pr?.repository?.name || null;
- const repoSlug = owner && name ? `${owner}/${name}` : null;
+ const repoSlug = extractRepoSlugFromPullRequest(pr);
if (!repoSlug) continue;
const prId = pr?.number ? `pr:${repoSlug}#${pr.number}` : null;
@@ -225,4 +239,3 @@ class ProcessProjectDashboardService {
}
module.exports = { ProcessProjectDashboardService };
-
diff --git a/server/processTaskService.js b/server/processTaskService.js
index 88c3f9e7..82f16e45 100644
--- a/server/processTaskService.js
+++ b/server/processTaskService.js
@@ -12,6 +12,24 @@ const logger = winston.createLogger({
]
});
+const extractRepoSlugFromPullRequest = (pr) => {
+ const nameWithOwner = String(pr?.repository?.nameWithOwner || '').trim();
+ if (nameWithOwner) return nameWithOwner;
+
+ const owner = pr?.repository?.owner?.login || pr?.repository?.owner?.name || null;
+ const name = pr?.repository?.name || null;
+ if (owner && name) return `${owner}/${name}`;
+
+ const repoSlug = String(pr?.repository || '').trim();
+ if (/^[^/]+\/[^/]+$/.test(repoSlug)) return repoSlug;
+
+ const url = String(pr?.url || '').trim();
+ const match = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/\d+/i);
+ if (match) return `${match[1]}/${match[2]}`;
+
+ return null;
+};
+
class ProcessTaskService {
constructor({ sessionManager, worktreeTagService, pullRequestService } = {}) {
this.sessionManager = sessionManager;
@@ -84,9 +102,7 @@ class ProcessTaskService {
});
return (result.prs || []).map(pr => {
- const owner = pr?.repository?.owner?.login || pr?.repository?.owner?.name || null;
- const name = pr?.repository?.name || null;
- const repoSlug = owner && name ? `${owner}/${name}` : null;
+ const repoSlug = extractRepoSlugFromPullRequest(pr);
return {
id: repoSlug && pr?.number ? `pr:${repoSlug}#${pr.number}` : `pr:${pr?.url || pr?.number || Math.random()}`,
@@ -128,4 +144,3 @@ class ProcessTaskService {
}
module.exports = { ProcessTaskService };
-
diff --git a/server/taskRecordService.js b/server/taskRecordService.js
index 339fe025..af92276b 100644
--- a/server/taskRecordService.js
+++ b/server/taskRecordService.js
@@ -209,6 +209,44 @@ const normalizeReviewChecklist = (raw) => {
return Object.keys(out).length ? out : null;
};
+const canonicalizePrTaskRecordId = (id) => {
+ const raw = String(id || '').trim();
+ if (!raw) return '';
+
+ const legacyMatch = raw.match(/^pr:https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/i);
+ if (legacyMatch) {
+ return `pr:${legacyMatch[1]}/${legacyMatch[2]}#${legacyMatch[3]}`;
+ }
+
+ return raw;
+};
+
+const toLegacyPrTaskRecordId = (id) => {
+ const raw = String(id || '').trim();
+ if (!raw) return null;
+
+ const canonicalMatch = raw.match(/^pr:([^/]+)\/([^#]+)#(\d+)$/i);
+ if (!canonicalMatch) return null;
+
+ return `pr:https://github.com/${canonicalMatch[1]}/${canonicalMatch[2]}/pull/${canonicalMatch[3]}`;
+};
+
+const resolveStoredTaskRecordId = (records, id) => {
+ const raw = String(id || '').trim();
+ if (!raw) return '';
+
+ const all = records && typeof records === 'object' ? records : {};
+ if (all[raw]) return raw;
+
+ const canonical = canonicalizePrTaskRecordId(raw);
+ if (canonical && canonical !== raw && all[canonical]) return canonical;
+
+ const legacy = toLegacyPrTaskRecordId(raw);
+ if (legacy && all[legacy]) return legacy;
+
+ return raw;
+};
+
class TaskRecordService {
constructor({ filePath } = {}) {
this.filePath = filePath || DEFAULT_PATH;
@@ -248,24 +286,37 @@ class TaskRecordService {
list() {
const records = this.data?.records || {};
- return Object.entries(records).map(([id]) => ({ id, ...(this.get(id) || {}) }));
+ const normalized = new Map();
+
+ for (const [storedId] of Object.entries(records)) {
+ const id = canonicalizePrTaskRecordId(storedId) || storedId;
+ const record = this.get(id) || this.get(storedId);
+ if (!record) continue;
+
+ if (!normalized.has(id) || storedId === id) {
+ normalized.set(id, { id, ...record });
+ }
+ }
+
+ return Array.from(normalized.values());
}
get(id) {
if (!id) return null;
- const local = this.data?.records?.[id] || null;
+ const storageId = resolveStoredTaskRecordId(this.data?.records, id);
+ const local = this.data?.records?.[storageId] || null;
const visibility = String(local?.recordVisibility || '').trim().toLowerCase();
if (visibility !== 'shared' && visibility !== 'encrypted') return local;
const repoRoot = String(local?.recordRepoRoot || '').trim();
- const relPath = String(local?.recordPath || '').trim() || getDefaultRepoTaskRecordPaths(id)[visibility];
+ const relPath = String(local?.recordPath || '').trim() || getDefaultRepoTaskRecordPaths(storageId)[visibility];
if (!repoRoot || !relPath) return local;
try {
const passphrase = visibility === 'encrypted' ? getTaskRecordPassphrase() : '';
if (visibility === 'encrypted' && !passphrase) return local;
- const fromRepo = readTaskRecordFromRepoSync({ id, repoRoot, relPath, visibility, passphrase });
+ const fromRepo = readTaskRecordFromRepoSync({ id: storageId, repoRoot, relPath, visibility, passphrase });
if (!fromRepo) return local;
return {
...stripRecordPointers(fromRepo),
@@ -733,10 +784,11 @@ class TaskRecordService {
if (!id) throw new Error('id is required');
if (!this.data.records) this.data.records = {};
+ const storageId = resolveStoredTaskRecordId(this.data.records, id);
const nowIso = new Date().toISOString();
- const existed = !!this.data.records[id];
- const existingLocal = this.data.records[id] || {};
+ const existed = !!this.data.records[storageId];
+ const existingLocal = this.data.records[storageId] || {};
const { next, clear } = this.normalizePatch(patch);
const pointerVisibility = String(next.recordVisibility || existingLocal.recordVisibility || 'private').trim().toLowerCase();
@@ -746,14 +798,14 @@ class TaskRecordService {
if (!toSharedOrEncrypted && (existingLocal.recordVisibility === 'shared' || existingLocal.recordVisibility === 'encrypted')) {
const oldVisibility = String(existingLocal.recordVisibility).trim().toLowerCase();
const repoRoot = String(existingLocal.recordRepoRoot || '').trim();
- const relPath = String(existingLocal.recordPath || '').trim() || getDefaultRepoTaskRecordPaths(id)[oldVisibility];
+ const relPath = String(existingLocal.recordPath || '').trim() || getDefaultRepoTaskRecordPaths(storageId)[oldVisibility];
if (repoRoot && relPath) {
try {
const passphrase = oldVisibility === 'encrypted' ? getTaskRecordPassphrase() : '';
if (oldVisibility !== 'encrypted' || passphrase) {
- const fromRepo = readTaskRecordFromRepoSync({ id, repoRoot, relPath, visibility: oldVisibility, passphrase });
+ const fromRepo = readTaskRecordFromRepoSync({ id: storageId, repoRoot, relPath, visibility: oldVisibility, passphrase });
if (fromRepo) {
- this.data.records[id] = {
+ this.data.records[storageId] = {
...stripRecordPointers(fromRepo),
createdAt: fromRepo.createdAt || existingLocal.createdAt || nowIso,
updatedAt: fromRepo.updatedAt || nowIso
@@ -766,7 +818,7 @@ class TaskRecordService {
}
}
- const baseLocal = this.data.records[id] || {};
+ const baseLocal = this.data.records[storageId] || {};
const nextNonPointers = {};
for (const [k, v] of Object.entries(next)) {
if (RECORD_POINTER_KEYS.has(k)) continue;
@@ -782,14 +834,14 @@ class TaskRecordService {
// private store (local JSON)
const merged = { ...mergedCandidate };
for (const k of Array.from(RECORD_POINTER_KEYS)) delete merged[k];
- this.data.records[id] = merged;
+ this.data.records[storageId] = merged;
await this.save();
return merged;
}
const repoRoot = String(next.recordRepoRoot || existingLocal.recordRepoRoot || '').trim();
if (!repoRoot) throw new Error('recordRepoRoot is required for shared/encrypted records');
- const relPath = String(next.recordPath || existingLocal.recordPath || '').trim() || getDefaultRepoTaskRecordPaths(id)[pointerVisibility];
+ const relPath = String(next.recordPath || existingLocal.recordPath || '').trim() || getDefaultRepoTaskRecordPaths(storageId)[pointerVisibility];
if (!relPath) throw new Error('recordPath is required for shared/encrypted records');
if (pointerVisibility === 'encrypted') {
@@ -803,7 +855,7 @@ class TaskRecordService {
const passphrase = pointerVisibility === 'encrypted' ? getTaskRecordPassphrase() : '';
const existingRepo = (() => {
try {
- return readTaskRecordFromRepoSync({ id, repoRoot, relPath, visibility: pointerVisibility, passphrase }) || null;
+ return readTaskRecordFromRepoSync({ id: storageId, repoRoot, relPath, visibility: pointerVisibility, passphrase }) || null;
} catch {
return null;
}
@@ -814,7 +866,7 @@ class TaskRecordService {
for (const k of clearNonPointers) delete mergedRepo[k];
await writeTaskRecordToRepo({
- id,
+ id: storageId,
repoRoot,
relPath,
visibility: pointerVisibility,
@@ -828,7 +880,7 @@ class TaskRecordService {
recordRepoRoot: repoRoot,
recordPath: relPath
};
- this.data.records[id] = cached;
+ this.data.records[storageId] = cached;
await this.save();
return cached;
}
@@ -836,8 +888,9 @@ class TaskRecordService {
async remove(id) {
if (!id) throw new Error('id is required');
if (!this.data.records) this.data.records = {};
- const existed = !!this.data.records[id];
- delete this.data.records[id];
+ const storageId = resolveStoredTaskRecordId(this.data.records, id);
+ const existed = !!this.data.records[storageId];
+ delete this.data.records[storageId];
await this.save();
return existed;
}
@@ -854,7 +907,8 @@ class TaskRecordService {
const root = String(repoRoot || '').trim();
if (!root) throw new Error('repoRoot is required');
- const defaults = this.defaultRepoTaskRecordPaths(taskId);
+ const storageId = resolveStoredTaskRecordId(this.data?.records, taskId);
+ const defaults = this.defaultRepoTaskRecordPaths(storageId);
const rp = String(relPath || defaults[store] || '').trim();
if (!rp) throw new Error('relPath is required');
@@ -863,11 +917,11 @@ class TaskRecordService {
throw new Error('Encrypted task records require ORCHESTRATOR_TASK_RECORDS_ENCRYPTION_KEY (or ORCHESTRATOR_TASK_RECORDS_PASSPHRASE) to be set');
}
- const existing = this.data?.records?.[taskId] || null;
+ const existing = this.data?.records?.[storageId] || null;
if (!existing) return null;
const record = stripRecordPointers(existing);
- await writeTaskRecordToRepo({ id: taskId, repoRoot: root, relPath: rp, visibility: store, record, passphrase });
+ await writeTaskRecordToRepo({ id: storageId, repoRoot: root, relPath: rp, visibility: store, record, passphrase });
const cached = {
...stripRecordPointers(record),
@@ -877,7 +931,7 @@ class TaskRecordService {
updatedAt: new Date().toISOString()
};
if (!cached.createdAt) cached.createdAt = existing?.createdAt || cached.updatedAt;
- this.data.records[taskId] = cached;
+ this.data.records[storageId] = cached;
await this.save();
return cached;
}
diff --git a/tests/unit/processTaskService.test.js b/tests/unit/processTaskService.test.js
index 9a9545d0..5e828f5a 100644
--- a/tests/unit/processTaskService.test.js
+++ b/tests/unit/processTaskService.test.js
@@ -10,7 +10,7 @@ describe('ProcessTaskService', () => {
title: 'PR title',
state: 'OPEN',
url: 'https://example.com/pr/5',
- repository: { name: 'repo', owner: { login: 'me' } },
+ repository: { name: 'repo', nameWithOwner: 'me/repo' },
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-03T00:00:00Z',
author: { login: 'me' }
@@ -42,8 +42,13 @@ describe('ProcessTaskService', () => {
const service = new ProcessTaskService({ sessionManager, worktreeTagService, pullRequestService });
const tasks = await service.listTasks();
+ const prTask = tasks.find(t => t.kind === 'pr' && t.prNumber === 5);
- expect(tasks.some(t => t.kind === 'pr' && t.prNumber === 5)).toBe(true);
+ expect(prTask).toMatchObject({
+ id: 'pr:me/repo#5',
+ repository: 'me/repo',
+ prNumber: 5
+ });
expect(tasks.some(t => t.kind === 'worktree' && t.worktreePath === '/tmp/work1')).toBe(true);
expect(tasks.some(t => t.kind === 'session' && t.sessionId === 's1')).toBe(true);
@@ -51,4 +56,3 @@ describe('ProcessTaskService', () => {
expect(tasks[0].kind).toBe('session');
});
});
-
diff --git a/tests/unit/taskRecordService.test.js b/tests/unit/taskRecordService.test.js
index 64fdcb63..77f23a00 100644
--- a/tests/unit/taskRecordService.test.js
+++ b/tests/unit/taskRecordService.test.js
@@ -151,6 +151,24 @@ describe('TaskRecordService', () => {
expect(rec2.reviewedAt).toBeUndefined();
});
+ test('legacy PR URL ids resolve through canonical ids', 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 legacyId = 'pr:https://github.com/me/repo/pull/42';
+ await svc.upsert(legacyId, { tier: 3, claimedBy: 'me' });
+
+ expect(svc.get('pr:me/repo#42')).toMatchObject({ tier: 3, claimedBy: 'me' });
+ expect(svc.list().map((r) => r.id)).toContain('pr:me/repo#42');
+
+ await svc.upsert('pr:me/repo#42', { reviewOutcome: 'needs_fix' });
+
+ const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
+ expect(raw.records[legacyId].reviewOutcome).toBe('needs_fix');
+ expect(raw.records['pr:me/repo#42']).toBeUndefined();
+ });
+
test('upsert supports overnight runner fields', async () => {
const svc = TaskRecordService.getInstance();
svc.data = { version: 1, records: {} };
From 3451ad17e775b3e0268eb2a005e6d4f5ca24f51b Mon Sep 17 00:00:00 2001
From: AnrokX <192667251+AnrokX@users.noreply.github.com>
Date: Tue, 10 Mar 2026 13:35:05 -0600
Subject: [PATCH 10/14] fix: scope review queue to active repo
---
client/app.js | 100 ++++++++++++++++++++++++++++++++++++++++++++------
1 file changed, 89 insertions(+), 11 deletions(-)
diff --git a/client/app.js b/client/app.js
index 161735dc..63d21c5e 100644
--- a/client/app.js
+++ b/client/app.js
@@ -935,6 +935,7 @@ class ClaudeOrchestrator {
});
document.getElementById('workflow-review')?.addEventListener('click', () => {
this.setWorkflowMode('review');
+ const reviewContext = this.getCurrentReviewContext();
// Open a simple PR-first picker; power-user review lanes still live under Flows.
this.queuePanelPreset = {
mode: 'mine',
@@ -947,9 +948,10 @@ class ClaudeOrchestrator {
autoConsole: false,
autoAdvance: false,
reviewActive: false,
- prioritizeActive: true
+ prioritizeActive: true,
+ projectFilter: reviewContext.projectFilter || ''
};
- this.showQueuePanel();
+ this.showQueuePanel({ selectedId: reviewContext.prTaskId || null });
});
document.getElementById('workflow-background')?.addEventListener('click', () => {
this.setWorkflowMode('background');
@@ -3169,7 +3171,8 @@ class ClaudeOrchestrator {
async openReviewInbox({ quick = false, project = '' } = {}) {
this.setWorkflowMode('review');
const defaults = this.getReviewInboxDefaults(quick ? 'quickReview' : 'reviewInbox');
- const projectFilter = String(project || defaults.project || '').trim();
+ const reviewContext = this.getCurrentReviewContext({ project });
+ const projectFilter = String(reviewContext.projectFilter || defaults.project || '').trim();
this.queuePanelPreset = {
mode: defaults.mode,
reviewTier: defaults.reviewTier,
@@ -3187,7 +3190,7 @@ class ClaudeOrchestrator {
prioritizeActive: defaults.prioritizeActive,
quickReview: !!quick
};
- return this.showQueuePanel();
+ return this.showQueuePanel({ selectedId: reviewContext.prTaskId || null });
}
async openQuickReview({ project = '' } = {}) {
@@ -3591,6 +3594,73 @@ class ClaudeOrchestrator {
return `pr:${owner}/${repo}#${prNum}`;
}
+ extractGitHubRepoSlug(value) {
+ const raw = String(value || '').trim();
+ if (!raw) return '';
+
+ const directMatch = raw.match(/^([^/\s]+)\/([^/\s]+?)(?:\.git)?$/);
+ if (directMatch) return `${directMatch[1]}/${directMatch[2]}`;
+
+ const normalized = raw.replace(/\.git(?=$|[/?#])/i, '');
+ const urlMatch = normalized.match(/github\.com[:/]([^/:\s]+)\/([^/#?\s]+?)(?:[/?#]|$)/i);
+ if (urlMatch) return `${urlMatch[1]}/${urlMatch[2]}`;
+
+ return '';
+ }
+
+ getCurrentReviewContext({ project = '' } = {}) {
+ const explicitProject = String(project || '').trim();
+ const explicitRepo = this.extractGitHubRepoSlug(explicitProject);
+ const candidateSessionIds = [];
+ const pushCandidate = (value) => {
+ const sid = String(value || '').trim();
+ if (!sid || candidateSessionIds.includes(sid)) return;
+ candidateSessionIds.push(sid);
+ };
+
+ pushCandidate(this.focusedTerminalInfo?.sessionId);
+ pushCandidate(this.lastInteractedSessionId);
+
+ const currentWorkspaceId = String(this.currentWorkspace?.id || '').trim();
+ for (const [sid, session] of this.sessions) {
+ if (!sid || !session) continue;
+ if (currentWorkspaceId) {
+ const sessionWorkspaceId = String(session?.workspace || '').trim();
+ if (sessionWorkspaceId && sessionWorkspaceId !== currentWorkspaceId) continue;
+ }
+ pushCandidate(sid);
+ }
+
+ let prTaskId = '';
+ let repoSlug = explicitRepo;
+
+ for (const sessionId of candidateSessionIds) {
+ const session = this.sessions.get(sessionId);
+ const links = this.githubLinks.get(sessionId) || {};
+
+ if (!prTaskId) prTaskId = this.getPRTaskIdFromUrl(links.pr) || '';
+ if (!repoSlug) repoSlug = String(session?.repositorySlug || '').trim();
+ if (!repoSlug) {
+ repoSlug = this.extractGitHubRepoSlug(links.pr)
+ || this.extractGitHubRepoSlug(links.commit)
+ || this.extractGitHubRepoSlug(session?.remoteUrl);
+ }
+ if (repoSlug && prTaskId) break;
+ }
+
+ if (!repoSlug) {
+ repoSlug = this.extractGitHubRepoSlug(this.currentWorkspace?.repository?.remote)
+ || this.extractGitHubRepoSlug(this.currentWorkspace?.remoteUrl);
+ }
+ if (!repoSlug && prTaskId) repoSlug = this.getRepositorySlugForPRTask({ id: prTaskId });
+
+ return {
+ repoSlug: repoSlug || '',
+ projectFilter: explicitProject || repoSlug || '',
+ prTaskId: prTaskId || ''
+ };
+ }
+
getRepositorySlugForPRTask(task) {
const t = task && typeof task === 'object' ? task : {};
const direct = String(t.repository || '').trim();
@@ -3603,10 +3673,7 @@ class ClaudeOrchestrator {
const prTaskMatch = raw.match(/^pr:([^/]+\/[^#]+)#\d+$/i);
if (prTaskMatch) return prTaskMatch[1];
- const urlMatch = raw.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/\d+/i);
- if (urlMatch) return `${urlMatch[1]}/${urlMatch[2]}`;
-
- return '';
+ return this.extractGitHubRepoSlug(raw);
};
return parse(t.url) || parse(t.id) || '';
@@ -25782,6 +25849,7 @@ class ClaudeOrchestrator {
const parts = raw.replace(/\\/g, '/').split('/').filter(Boolean);
return parts[parts.length - 1] || raw;
};
+ const extractRepoSlug = (value) => this.extractGitHubRepoSlug(value);
const updateProjectFilterOptions = () => {
if (!projectFilterEl) return;
@@ -25794,8 +25862,11 @@ class ClaudeOrchestrator {
if (!label) continue;
options.set(normalizeProjectKey(label), label);
}
- const sorted = Array.from(options.values()).sort((a, b) => String(a).localeCompare(String(b)));
const current = normalizeProjectKey(state.projectFilter);
+ if (current && state.projectFilter && !options.has(current)) {
+ options.set(current, state.projectFilter);
+ }
+ const sorted = Array.from(options.values()).sort((a, b) => String(a).localeCompare(String(b)));
projectFilterEl.innerHTML = `
` + sorted
.map((label) => `
`)
.join('');
@@ -26352,6 +26423,8 @@ class ClaudeOrchestrator {
url.searchParams.set('mode', state.mode);
url.searchParams.set('state', 'open');
url.searchParams.set('include', 'dependencySummary');
+ const repoSlug = extractRepoSlug(state.projectFilter);
+ if (repoSlug) url.searchParams.set('repo', repoSlug);
const res = await fetch(url.toString());
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error || 'Failed to load queue');
@@ -29262,9 +29335,14 @@ class ClaudeOrchestrator {
if (state.selectedId) renderDetail(getTaskById(state.selectedId));
});
- projectFilterEl?.addEventListener('change', () => {
+ projectFilterEl?.addEventListener('change', async () => {
state.projectFilter = String(projectFilterEl.value || '');
- applyFiltersAndMaybeClampSelection();
+ try {
+ await fetchTasks();
+ if (state.selectedId) renderDetail(getTaskById(state.selectedId));
+ } catch (e) {
+ this.showToast(String(e?.message || e), 'error');
+ }
});
refreshBtn.addEventListener('click', async () => {
From 75ca50d31259f9a376c5cc7f3890691b8f4d7b6a Mon Sep 17 00:00:00 2001
From: AnrokX <192667251+AnrokX@users.noreply.github.com>
Date: Tue, 10 Mar 2026 13:44:32 -0600
Subject: [PATCH 11/14] fix: keep review queue scoped when filters change
---
client/app.js | 20 +++++++++++++++++---
1 file changed, 17 insertions(+), 3 deletions(-)
diff --git a/client/app.js b/client/app.js
index 63d21c5e..63687560 100644
--- a/client/app.js
+++ b/client/app.js
@@ -949,7 +949,8 @@ class ClaudeOrchestrator {
autoAdvance: false,
reviewActive: false,
prioritizeActive: true,
- projectFilter: reviewContext.projectFilter || ''
+ projectFilter: reviewContext.projectFilter || '',
+ repoScope: reviewContext.repoSlug || ''
};
this.showQueuePanel({ selectedId: reviewContext.prTaskId || null });
});
@@ -3187,6 +3188,7 @@ class ClaudeOrchestrator {
reviewRouteActive: false,
kindFilter: defaults.kind,
projectFilter: projectFilter || '',
+ repoScope: reviewContext.repoSlug || '',
prioritizeActive: defaults.prioritizeActive,
quickReview: !!quick
};
@@ -3656,7 +3658,7 @@ class ClaudeOrchestrator {
return {
repoSlug: repoSlug || '',
- projectFilter: explicitProject || repoSlug || '',
+ projectFilter: explicitProject || '',
prTaskId: prTaskId || ''
};
}
@@ -25157,6 +25159,7 @@ class ClaudeOrchestrator {
mode: 'mine', // mine | all
kindFilter: 'all', // all | pr | worktree | session
projectFilter: '',
+ repoScope: '',
prioritizeActive: localStorage.getItem('queue-prioritize-active') === 'true',
query: '',
tasks: [],
@@ -25262,6 +25265,9 @@ class ClaudeOrchestrator {
if (preset.projectFilter !== undefined) {
state.projectFilter = String(preset.projectFilter || '').trim();
}
+ if (preset.repoScope !== undefined) {
+ state.repoScope = String(preset.repoScope || '').trim();
+ }
if (preset.prioritizeActive !== undefined) {
state.prioritizeActive = !!preset.prioritizeActive;
try { localStorage.setItem('queue-prioritize-active', state.prioritizeActive ? 'true' : 'false'); } catch {}
@@ -25434,6 +25440,9 @@ class ClaudeOrchestrator {
}
if (projectFilterEl) {
projectFilterEl.value = String(state.projectFilter || '');
+ projectFilterEl.title = state.repoScope
+ ? `Project filter within ${state.repoScope}`
+ : 'Project filter';
}
const showPairingModal = async () => {
@@ -25541,6 +25550,11 @@ class ClaudeOrchestrator {
const setMode = (mode) => {
state.mode = mode === 'all' ? 'all' : 'mine';
+ const hasRepoScope = !!String(state.repoScope || '').trim();
+ mineBtn.textContent = 'Mine';
+ mineBtn.title = hasRepoScope ? `My PRs in ${state.repoScope}` : 'My PRs across GitHub';
+ allBtn.textContent = hasRepoScope ? 'Repo' : 'All';
+ allBtn.title = hasRepoScope ? `All PRs in ${state.repoScope}` : 'All PRs across GitHub';
mineBtn.classList.toggle('active', state.mode === 'mine');
allBtn.classList.toggle('active', state.mode === 'all');
};
@@ -26423,7 +26437,7 @@ class ClaudeOrchestrator {
url.searchParams.set('mode', state.mode);
url.searchParams.set('state', 'open');
url.searchParams.set('include', 'dependencySummary');
- const repoSlug = extractRepoSlug(state.projectFilter);
+ const repoSlug = String(state.repoScope || '').trim() || extractRepoSlug(state.projectFilter);
if (repoSlug) url.searchParams.set('repo', repoSlug);
const res = await fetch(url.toString());
const data = await res.json().catch(() => ({}));
From 17303324d570000246a7a8bb50f0702cf437b12d Mon Sep 17 00:00:00 2001
From: AnrokX <192667251+AnrokX@users.noreply.github.com>
Date: Tue, 10 Mar 2026 13:55:44 -0600
Subject: [PATCH 12/14] fix: clarify review queue defaults and filters
---
client/app.js | 91 +++++++++++++++++++++++++++-----------
user-settings.default.json | 4 +-
2 files changed, 67 insertions(+), 28 deletions(-)
diff --git a/client/app.js b/client/app.js
index 63687560..d5b94764 100644
--- a/client/app.js
+++ b/client/app.js
@@ -949,8 +949,8 @@ class ClaudeOrchestrator {
autoAdvance: false,
reviewActive: false,
prioritizeActive: true,
- projectFilter: reviewContext.projectFilter || '',
- repoScope: reviewContext.repoSlug || ''
+ projectFilter: '',
+ repoScope: ''
};
this.showQueuePanel({ selectedId: reviewContext.prTaskId || null });
});
@@ -3174,6 +3174,7 @@ class ClaudeOrchestrator {
const defaults = this.getReviewInboxDefaults(quick ? 'quickReview' : 'reviewInbox');
const reviewContext = this.getCurrentReviewContext({ project });
const projectFilter = String(reviewContext.projectFilter || defaults.project || '').trim();
+ const repoScope = projectFilter ? (reviewContext.repoSlug || '') : '';
this.queuePanelPreset = {
mode: defaults.mode,
reviewTier: defaults.reviewTier,
@@ -3188,7 +3189,7 @@ class ClaudeOrchestrator {
reviewRouteActive: false,
kindFilter: defaults.kind,
projectFilter: projectFilter || '',
- repoScope: reviewContext.repoSlug || '',
+ repoScope: repoScope,
prioritizeActive: defaults.prioritizeActive,
quickReview: !!quick
};
@@ -25317,14 +25318,14 @@ class ClaudeOrchestrator {
-
-
+
+
-
+
@@ -25442,7 +25443,7 @@ class ClaudeOrchestrator {
projectFilterEl.value = String(state.projectFilter || '');
projectFilterEl.title = state.repoScope
? `Project filter within ${state.repoScope}`
- : 'Project filter';
+ : 'Filter the current PR list by repo or project';
}
const showPairingModal = async () => {
@@ -25551,10 +25552,10 @@ class ClaudeOrchestrator {
const setMode = (mode) => {
state.mode = mode === 'all' ? 'all' : 'mine';
const hasRepoScope = !!String(state.repoScope || '').trim();
- mineBtn.textContent = 'Mine';
- mineBtn.title = hasRepoScope ? `My PRs in ${state.repoScope}` : 'My PRs across GitHub';
- allBtn.textContent = hasRepoScope ? 'Repo' : 'All';
- allBtn.title = hasRepoScope ? `All PRs in ${state.repoScope}` : 'All PRs across GitHub';
+ mineBtn.textContent = 'My PRs';
+ mineBtn.title = hasRepoScope ? `Only my PRs in ${state.repoScope}` : 'Only PRs authored by me';
+ allBtn.textContent = hasRepoScope ? 'This Repo' : 'Any Author';
+ allBtn.title = hasRepoScope ? `PRs from any author in ${state.repoScope}` : 'PRs from any author';
mineBtn.classList.toggle('active', state.mode === 'mine');
allBtn.classList.toggle('active', state.mode === 'all');
};
@@ -25881,7 +25882,7 @@ class ClaudeOrchestrator {
options.set(current, state.projectFilter);
}
const sorted = Array.from(options.values()).sort((a, b) => String(a).localeCompare(String(b)));
- projectFilterEl.innerHTML = `
` + sorted
+ projectFilterEl.innerHTML = `
` + sorted
.map((label) => `
`)
.join('');
if (current && options.has(current)) {
@@ -26006,54 +26007,89 @@ class ClaudeOrchestrator {
const buildActiveIndex = () => {
const activeSessionIds = new Set();
+ const openSessionIds = new Set();
const activeWorktreeIds = new Set();
+ const openWorktreeIds = new Set();
const activeWorktreePaths = new Set();
+ const openWorktreePaths = new Set();
const activeRepoNames = new Set();
+ const openRepoNames = new Set();
const activeRepoSlugs = new Set();
+ const openRepoSlugs = new Set();
for (const [sid, session] of this.sessions) {
if (!this.isAgentSession(sid)) continue;
const status = String(session?.status || '').trim().toLowerCase();
if (status === 'exited') continue;
+ openSessionIds.add(String(sid));
+
+ const worktreeId = String(session?.worktreeId || '').trim();
+ if (worktreeId) openWorktreeIds.add(worktreeId);
+
+ const worktreePath = String(session?.config?.cwd || '').trim();
+ if (worktreePath) openWorktreePaths.add(worktreePath);
+
+ const repoName = String(session?.repositoryName || '').trim();
+ if (repoName) openRepoNames.add(normalizeProjectKey(repoName));
+
+ const repoRoot = String(session?.repositoryRoot || '').trim();
+ const repoRootName = extractRepoName(repoRoot);
+ if (repoRootName) openRepoNames.add(normalizeProjectKey(repoRootName));
+
+ const repoSlug = String(session?.repositorySlug || '').trim();
+ if (repoSlug) openRepoSlugs.add(normalizeProjectKey(repoSlug));
+
const hasActivity = this.sessionActivity.get(sid) === 'active' || status === 'busy' || status === 'waiting';
if (!hasActivity) continue;
activeSessionIds.add(String(sid));
- const worktreeId = String(session?.worktreeId || '').trim();
if (worktreeId) activeWorktreeIds.add(worktreeId);
- const worktreePath = String(session?.config?.cwd || '').trim();
if (worktreePath) activeWorktreePaths.add(worktreePath);
- const repoName = String(session?.repositoryName || '').trim();
if (repoName) activeRepoNames.add(normalizeProjectKey(repoName));
- const repoRoot = String(session?.repositoryRoot || '').trim();
- const repoRootName = extractRepoName(repoRoot);
if (repoRootName) activeRepoNames.add(normalizeProjectKey(repoRootName));
- const repoSlug = String(session?.repositorySlug || '').trim();
if (repoSlug) activeRepoSlugs.add(normalizeProjectKey(repoSlug));
}
- return { activeSessionIds, activeWorktreeIds, activeWorktreePaths, activeRepoNames, activeRepoSlugs };
+ return {
+ activeSessionIds,
+ openSessionIds,
+ activeWorktreeIds,
+ openWorktreeIds,
+ activeWorktreePaths,
+ openWorktreePaths,
+ activeRepoNames,
+ openRepoNames,
+ activeRepoSlugs,
+ openRepoSlugs
+ };
};
const getActiveScoreForTask = (t, index) => {
if (!index) return 0;
if (t?.kind === 'session') {
const sid = String(t?.sessionId || '').trim();
- return (sid && index.activeSessionIds.has(sid)) ? 3 : 0;
+ if (sid && index.activeSessionIds.has(sid)) return 5;
+ return (sid && index.openSessionIds.has(sid)) ? 4 : 0;
}
if (t?.kind === 'worktree') {
const path = String(t?.worktreePath || '').trim();
- if (path && index.activeWorktreePaths.has(path)) return 2;
+ if (path && index.activeWorktreePaths.has(path)) return 4;
+ if (path && index.openWorktreePaths.has(path)) return 3;
const worktreeId = String(t?.worktreeId || '').trim();
- if (worktreeId && index.activeWorktreeIds.has(worktreeId)) return 2;
+ if (worktreeId && index.activeWorktreeIds.has(worktreeId)) return 4;
+ if (worktreeId && index.openWorktreeIds.has(worktreeId)) return 3;
return 0;
}
if (t?.kind === 'pr') {
+ const linkedSessionIds = this.getLinkedSessionIdsForPrTask(t?.id);
+ if (linkedSessionIds.some((sid) => index.activeSessionIds.has(String(sid || '').trim()))) return 6;
+ if (linkedSessionIds.some((sid) => index.openSessionIds.has(String(sid || '').trim()))) return 5;
+
const rec = (t?.record && typeof t.record === 'object') ? t.record : {};
const worktreeIds = [
rec.reviewerWorktreeId,
@@ -26061,14 +26097,17 @@ class ClaudeOrchestrator {
rec.recheckWorktreeId,
rec.overnightWorktreeId
].map((v) => String(v || '').trim()).filter(Boolean);
- if (worktreeIds.some((id) => index.activeWorktreeIds.has(id))) return 3;
+ if (worktreeIds.some((id) => index.activeWorktreeIds.has(id))) return 4;
+ if (worktreeIds.some((id) => index.openWorktreeIds.has(id))) return 3;
const repoSlug = normalizeProjectKey(t?.repository || '');
- if (repoSlug && index.activeRepoSlugs.has(repoSlug)) return 2;
+ if (repoSlug && index.activeRepoSlugs.has(repoSlug)) return 3;
+ if (repoSlug && index.openRepoSlugs.has(repoSlug)) return 2;
const repoName = normalizeProjectKey(extractRepoName(t?.repository || ''));
const projectName = normalizeProjectKey(t?.project || '');
- if ((repoName && index.activeRepoNames.has(repoName)) || (projectName && index.activeRepoNames.has(projectName))) return 2;
+ if ((repoName && index.activeRepoNames.has(repoName)) || (projectName && index.activeRepoNames.has(projectName))) return 3;
+ if ((repoName && index.openRepoNames.has(repoName)) || (projectName && index.openRepoNames.has(projectName))) return 2;
}
return 0;
};
diff --git a/user-settings.default.json b/user-settings.default.json
index d2911f9f..2e5977af 100644
--- a/user-settings.default.json
+++ b/user-settings.default.json
@@ -177,9 +177,9 @@
},
"reviewInbox": {
"mode": "mine",
- "tiers": "t3t4",
+ "tiers": "all",
"kind": "pr",
- "unreviewedOnly": true,
+ "unreviewedOnly": false,
"autoConsole": false,
"autoAdvance": false,
"prioritizeActive": true,
From ea51b0978fa4e88d0d16880c0fd64009b3f28ca4 Mon Sep 17 00:00:00 2001
From: AnrokX <192667251+AnrokX@users.noreply.github.com>
Date: Tue, 10 Mar 2026 14:23:58 -0600
Subject: [PATCH 13/14] fix: scope review queue to workspace repos
---
client/app.js | 122 +++++++++++++++++++++++++++++++++++++-------------
1 file changed, 92 insertions(+), 30 deletions(-)
diff --git a/client/app.js b/client/app.js
index d5b94764..6416af76 100644
--- a/client/app.js
+++ b/client/app.js
@@ -950,7 +950,7 @@ class ClaudeOrchestrator {
reviewActive: false,
prioritizeActive: true,
projectFilter: '',
- repoScope: ''
+ repoScope: reviewContext.repoScope || ''
};
this.showQueuePanel({ selectedId: reviewContext.prTaskId || null });
});
@@ -3174,7 +3174,6 @@ class ClaudeOrchestrator {
const defaults = this.getReviewInboxDefaults(quick ? 'quickReview' : 'reviewInbox');
const reviewContext = this.getCurrentReviewContext({ project });
const projectFilter = String(reviewContext.projectFilter || defaults.project || '').trim();
- const repoScope = projectFilter ? (reviewContext.repoSlug || '') : '';
this.queuePanelPreset = {
mode: defaults.mode,
reviewTier: defaults.reviewTier,
@@ -3189,7 +3188,7 @@ class ClaudeOrchestrator {
reviewRouteActive: false,
kindFilter: defaults.kind,
projectFilter: projectFilter || '',
- repoScope: repoScope,
+ repoScope: reviewContext.repoScope || '',
prioritizeActive: defaults.prioritizeActive,
quickReview: !!quick
};
@@ -3615,50 +3614,101 @@ class ClaudeOrchestrator {
const explicitProject = String(project || '').trim();
const explicitRepo = this.extractGitHubRepoSlug(explicitProject);
const candidateSessionIds = [];
+ const repoSlugs = new Set();
const pushCandidate = (value) => {
const sid = String(value || '').trim();
if (!sid || candidateSessionIds.includes(sid)) return;
candidateSessionIds.push(sid);
};
+ const addRepoSlug = (value) => {
+ const slug = this.extractGitHubRepoSlug(value);
+ if (!slug) return '';
+ repoSlugs.add(slug);
+ return slug;
+ };
+ const normalizePath = (value) => String(value || '').trim().replace(/\\/g, '/').replace(/\/+$/, '');
+ const workspaceTerminalIds = new Set(
+ (Array.isArray(this.currentWorkspace?.terminals) ? this.currentWorkspace.terminals : [])
+ .map((terminal) => String(terminal?.id || '').trim())
+ .filter(Boolean)
+ );
+ const workspaceRepoPaths = new Set(
+ (Array.isArray(this.currentWorkspace?.terminals) ? this.currentWorkspace.terminals : [])
+ .map((terminal) => normalizePath(terminal?.repository?.path))
+ .filter(Boolean)
+ );
+ const workspaceRepoPath = normalizePath(this.currentWorkspace?.repository?.path);
+ const addSessionRepoContext = (sessionId, session) => {
+ if (!sessionId || !session) return;
+ const links = this.githubLinks.get(sessionId) || {};
+ addRepoSlug(session?.repositorySlug);
+ addRepoSlug(links.pr);
+ addRepoSlug(links.commit);
+ addRepoSlug(session?.remoteUrl);
+ };
+ const deriveSessionRepoPath = (sessionId, session) => {
+ const directRoot = normalizePath(session?.repositoryRoot);
+ if (directRoot) return directRoot;
+ const cwd = normalizePath(this.resolveWorktreePathForSession(sessionId, session));
+ const worktreeId = String(session?.worktreeId || '').trim();
+ if (cwd && worktreeId && cwd.endsWith(`/${worktreeId}`)) {
+ return cwd.slice(0, -(`/${worktreeId}`).length);
+ }
+ return '';
+ };
+ const belongsToCurrentWorkspace = (sessionId, session) => {
+ const sid = String(sessionId || '').trim();
+ if (!sid || !session) return false;
+
+ const currentWorkspaceId = String(this.currentWorkspace?.id || '').trim();
+ const sessionWorkspaceId = String(session?.workspace || '').trim();
+ if (currentWorkspaceId && sessionWorkspaceId) return sessionWorkspaceId === currentWorkspaceId;
+
+ if (workspaceTerminalIds.size && workspaceTerminalIds.has(sid)) return true;
+
+ const sessionRepoPath = deriveSessionRepoPath(sid, session);
+ if (workspaceRepoPath && sessionRepoPath && sessionRepoPath === workspaceRepoPath) return true;
+ if (workspaceRepoPaths.size && sessionRepoPath && workspaceRepoPaths.has(sessionRepoPath)) return true;
+
+ return !currentWorkspaceId;
+ };
pushCandidate(this.focusedTerminalInfo?.sessionId);
pushCandidate(this.lastInteractedSessionId);
- const currentWorkspaceId = String(this.currentWorkspace?.id || '').trim();
- for (const [sid, session] of this.sessions) {
- if (!sid || !session) continue;
- if (currentWorkspaceId) {
- const sessionWorkspaceId = String(session?.workspace || '').trim();
- if (sessionWorkspaceId && sessionWorkspaceId !== currentWorkspaceId) continue;
- }
- pushCandidate(sid);
- }
-
let prTaskId = '';
- let repoSlug = explicitRepo;
+ let repoSlug = addRepoSlug(explicitRepo);
for (const sessionId of candidateSessionIds) {
const session = this.sessions.get(sessionId);
+ if (!session) continue;
const links = this.githubLinks.get(sessionId) || {};
if (!prTaskId) prTaskId = this.getPRTaskIdFromUrl(links.pr) || '';
- if (!repoSlug) repoSlug = String(session?.repositorySlug || '').trim();
+ if (!repoSlug) repoSlug = addRepoSlug(session?.repositorySlug);
if (!repoSlug) {
- repoSlug = this.extractGitHubRepoSlug(links.pr)
- || this.extractGitHubRepoSlug(links.commit)
- || this.extractGitHubRepoSlug(session?.remoteUrl);
+ repoSlug = addRepoSlug(links.pr)
+ || addRepoSlug(links.commit)
+ || addRepoSlug(session?.remoteUrl);
}
- if (repoSlug && prTaskId) break;
+ addSessionRepoContext(sessionId, session);
}
- if (!repoSlug) {
- repoSlug = this.extractGitHubRepoSlug(this.currentWorkspace?.repository?.remote)
- || this.extractGitHubRepoSlug(this.currentWorkspace?.remoteUrl);
+ for (const [sid, session] of this.sessions) {
+ if (!belongsToCurrentWorkspace(sid, session)) continue;
+ addSessionRepoContext(sid, session);
}
+
+ addRepoSlug(this.currentWorkspace?.repository?.remote);
+ addRepoSlug(this.currentWorkspace?.remoteUrl);
+
+ if (!repoSlug) repoSlug = Array.from(repoSlugs)[0] || '';
if (!repoSlug && prTaskId) repoSlug = this.getRepositorySlugForPRTask({ id: prTaskId });
+ if (repoSlug) addRepoSlug(repoSlug);
return {
repoSlug: repoSlug || '',
+ repoScope: Array.from(repoSlugs).join(','),
projectFilter: explicitProject || '',
prTaskId: prTaskId || ''
};
@@ -25322,7 +25372,7 @@ class ClaudeOrchestrator {
-
+
@@ -25441,9 +25491,12 @@ class ClaudeOrchestrator {
}
if (projectFilterEl) {
projectFilterEl.value = String(state.projectFilter || '');
- projectFilterEl.title = state.repoScope
- ? `Project filter within ${state.repoScope}`
- : 'Filter the current PR list by repo or project';
+ const scopedRepos = String(state.repoScope || '').split(',').map((value) => String(value || '').trim()).filter(Boolean);
+ projectFilterEl.title = scopedRepos.length > 1
+ ? 'Filter within the current workspace repos'
+ : scopedRepos.length === 1
+ ? `Project filter within ${scopedRepos[0]}`
+ : 'Filter the current PR list by repo or project';
}
const showPairingModal = async () => {
@@ -25551,11 +25604,20 @@ class ClaudeOrchestrator {
const setMode = (mode) => {
state.mode = mode === 'all' ? 'all' : 'mine';
- const hasRepoScope = !!String(state.repoScope || '').trim();
+ const scopedRepos = String(state.repoScope || '').split(',').map((value) => String(value || '').trim()).filter(Boolean);
+ const repoCount = scopedRepos.length;
mineBtn.textContent = 'My PRs';
- mineBtn.title = hasRepoScope ? `Only my PRs in ${state.repoScope}` : 'Only PRs authored by me';
- allBtn.textContent = hasRepoScope ? 'This Repo' : 'Any Author';
- allBtn.title = hasRepoScope ? `PRs from any author in ${state.repoScope}` : 'PRs from any author';
+ mineBtn.title = repoCount > 1
+ ? 'Only my PRs in the current workspace repos'
+ : repoCount === 1
+ ? `Only my PRs in ${scopedRepos[0]}`
+ : 'Only PRs authored by me';
+ allBtn.textContent = repoCount > 1 ? 'Workspace PRs' : repoCount === 1 ? 'This Repo' : 'Any Author';
+ allBtn.title = repoCount > 1
+ ? 'PRs from any author in the current workspace repos'
+ : repoCount === 1
+ ? `PRs from any author in ${scopedRepos[0]}`
+ : 'PRs from any author';
mineBtn.classList.toggle('active', state.mode === 'mine');
allBtn.classList.toggle('active', state.mode === 'all');
};
From 1fe629190a57292436d0e5b33dd8cb43b9917230 Mon Sep 17 00:00:00 2001
From: AnrokX <192667251+AnrokX@users.noreply.github.com>
Date: Tue, 10 Mar 2026 14:46:07 -0600
Subject: [PATCH 14/14] fix: make auto reviewer behave immediately
---
client/app.js | 87 +++++++++++++++++++++++++++++++++++----------------
1 file changed, 60 insertions(+), 27 deletions(-)
diff --git a/client/app.js b/client/app.js
index 6416af76..ecdd90e8 100644
--- a/client/app.js
+++ b/client/app.js
@@ -25396,7 +25396,7 @@ class ClaudeOrchestrator {
-
+
@@ -25743,11 +25743,19 @@ class ClaudeOrchestrator {
syncReviewControlsUI();
});
- autoReviewerBtn?.addEventListener('click', () => {
+ autoReviewerBtn?.addEventListener('click', async () => {
state.autoReviewer = !state.autoReviewer;
localStorage.setItem('queue-auto-reviewer', state.autoReviewer ? 'true' : 'false');
syncReviewControlsUI();
if (state.selectedId) renderDetail(getTaskById(state.selectedId));
+ if (!state.autoReviewer) {
+ this.showToast?.('Auto Reviewer disabled', 'info');
+ return;
+ }
+ const result = await triggerAutoReviewerForQueue({ notify: true, preferSelected: true });
+ if (!result?.started && result?.reason === 'no_candidate') {
+ this.showToast?.('Auto Reviewer armed: it will start on the selected or next visible unreviewed PR', 'info');
+ }
});
autoFixerBtn?.addEventListener('click', () => {
@@ -26552,6 +26560,9 @@ class ClaudeOrchestrator {
state.selectedId = ordered[0]?.id || null;
}
renderList();
+ if (state.autoReviewer) {
+ Promise.resolve().then(() => triggerAutoReviewerForQueue({ notify: false, preferSelected: true })).catch(() => {});
+ }
refreshConflicts().catch(() => {});
};
@@ -29296,35 +29307,57 @@ class ClaudeOrchestrator {
}
};
- const maybeAutoSpawnReviewer = async (t) => {
- if (!state.autoReviewer) return;
- const task = t || {};
- if (task.kind !== 'pr') return;
+ const getAutoReviewerEligibility = (task) => {
+ const t = task || {};
+ if (!state.autoReviewer) return { ok: false, reason: 'disabled' };
+ if (t.kind !== 'pr') return { ok: false, reason: 'not_pr' };
+ if (t?.record?.reviewedAt) return { ok: false, reason: 'reviewed' };
+ if (t?.record?.reviewerSpawnedAt) return { ok: false, reason: 'already_started' };
+ if (state.reviewerSpawning?.has?.(t.id)) return { ok: false, reason: 'starting' };
+ return { ok: true, reason: '' };
+ };
- const tier = Number(task?.record?.tier);
- if (tier !== 3) return;
+ const getAutoReviewerCandidate = ({ preferSelected = true } = {}) => {
+ if (preferSelected && state.selectedId) {
+ const selected = getTaskById(state.selectedId);
+ if (getAutoReviewerEligibility(selected).ok) return selected;
+ }
+ const ordered = getOrderedTasks(getFilteredTasks());
+ return ordered.find((task) => getAutoReviewerEligibility(task).ok) || null;
+ };
- if (task?.record?.reviewedAt) return;
- if (task?.record?.reviewerSpawnedAt) return;
+ const maybeAutoSpawnReviewer = async (task, { silent = true } = {}) => {
+ const eligibility = getAutoReviewerEligibility(task);
+ if (!eligibility.ok) return false;
+ const nextTask = task || {};
- if (state.reviewerSpawning?.has?.(task.id)) return;
- state.reviewerSpawning.add(task.id);
+ state.reviewerSpawning.add(nextTask.id);
- try {
- const info = await this.spawnReviewAgentForPRTask(task, { tier: 3, agentId: resolveQueueAgentId(), mode: 'fresh', yolo: true });
- if (!info) return;
- const patch = { reviewerSpawnedAt: new Date().toISOString(), ...buildReviewSourcePatch(task) };
- if (info?.worktreeId) patch.reviewerWorktreeId = info.worktreeId;
- const rec = await upsertRecord(task.id, patch);
- updateTaskRecordInState(task.id, rec);
- renderList();
- renderDetail(getTaskById(task.id));
- } catch (e) {
- // best-effort; keep it silent unless it was user-initiated
- console.warn('Auto reviewer spawn failed:', e);
- } finally {
- state.reviewerSpawning.delete(task.id);
- }
+ try {
+ const info = await this.spawnReviewAgentForPRTask(nextTask, { tier: 3, agentId: resolveQueueAgentId(), mode: 'fresh', yolo: true });
+ if (!info) return false;
+ const patch = { reviewerSpawnedAt: new Date().toISOString(), ...buildReviewSourcePatch(nextTask) };
+ if (info?.worktreeId) patch.reviewerWorktreeId = info.worktreeId;
+ const rec = await upsertRecord(nextTask.id, patch);
+ updateTaskRecordInState(nextTask.id, rec);
+ renderList();
+ renderDetail(getTaskById(nextTask.id));
+ return true;
+ } catch (e) {
+ if (!silent) this.showToast?.(String(e?.message || e), 'error');
+ else console.warn('Auto reviewer spawn failed:', e);
+ return false;
+ } finally {
+ state.reviewerSpawning.delete(nextTask.id);
+ }
+ };
+
+ const triggerAutoReviewerForQueue = async ({ notify = false, preferSelected = true } = {}) => {
+ if (!state.autoReviewer) return { started: false, reason: 'disabled' };
+ const candidate = getAutoReviewerCandidate({ preferSelected });
+ if (!candidate) return { started: false, reason: 'no_candidate' };
+ const started = await maybeAutoSpawnReviewer(candidate, { silent: !notify });
+ return { started, taskId: candidate?.id || '', reason: started ? '' : 'failed' };
};
const parseIsoMaybe = (v) => {