Prototype of a Roomote tab (#147)

Co-authored-by: cte <cestreich@gmail.com>
Co-authored-by: Roomote <chris@roocode.com>
Co-authored-by: John Richmond <5629+jr@users.noreply.github.com>
This commit is contained in:
Matt Rubens 2025-07-07 13:52:18 -04:00 committed by GitHub
parent 7dcdf6039c
commit 17ce731867
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 3507 additions and 578 deletions

2
.gitignore vendored
View file

@ -26,4 +26,4 @@ Thumbs.db
*.log
# project mcp config
.roo/mcp.json
.roo/mcp.json

View file

@ -3,7 +3,6 @@
## Testing & Formatting
- **Test**: `pnpm test` (runs all tests via Turbo)
- **Test single file**: `pnpm test path/to/file.test.ts`
- **Lint**: `pnpm lint` (ESLint with TypeScript support)
- **Type check**: `pnpm check-types`
- **Format**: Files are auto-formatted on commit via lint-staged

View file

@ -13,8 +13,6 @@
"controller:production": "dotenvx run -f ../../.env.production -- tsx src/lib/controller.ts",
"worker": "dotenvx run -f ../../.env.development -- tsx src/lib/worker.ts",
"worker:production": "dotenvx run -f ../../.env.production -- tsx src/lib/worker.ts",
"cli": "dotenvx run -f ../../.env.development -- tsx src/lib/cli.ts",
"cli:production": "dotenvx run -f ../../.env.production -- tsx src/lib/cli.ts",
"clean": "rimraf .next .turbo"
},
"dependencies": {
@ -24,7 +22,6 @@
"@roo-code-cloud/job-auth": "workspace:^",
"@roo-code/types": "^1.30.0",
"bullmq": "^5.37.0",
"cmd-ts": "^0.13.0",
"drizzle-orm": "^0.44.2",
"execa": "^9.6.0",
"ioredis": "^5.6.1",

View file

@ -87,6 +87,7 @@ describe('GitHub Webhook Utils', () => {
const mockJob = { id: 123 };
const mockEnqueuedJob = { id: 'enqueued-123' };
const mockCloudJobs = {};
const testOrgId = 'test-org-123';
beforeEach(() => {
mockDb.insert.mockReturnValue({
@ -107,13 +108,14 @@ describe('GitHub Webhook Utils', () => {
body: 'Test body',
};
const result = await createAndEnqueueJob(type, payload);
const result = await createAndEnqueueJob(type, payload, testOrgId);
expect(mockDb.insert).toHaveBeenCalledWith(mockCloudJobs);
expect(mockEnqueue).toHaveBeenCalledWith({
jobId: mockJob.id,
type,
payload,
orgId: testOrgId,
});
expect(result).toEqual({
jobId: mockJob.id,
@ -136,9 +138,9 @@ describe('GitHub Webhook Utils', () => {
body: 'Test',
};
await expect(createAndEnqueueJob(type, payload)).rejects.toThrow(
'Failed to create `cloudJobs` record.',
);
await expect(
createAndEnqueueJob(type, payload, testOrgId),
).rejects.toThrow('Failed to create `cloudJobs` record.');
});
it('should throw error when enqueue fails to return job ID', async () => {
@ -152,9 +154,9 @@ describe('GitHub Webhook Utils', () => {
body: 'Test',
};
await expect(createAndEnqueueJob(type, payload)).rejects.toThrow(
'Failed to get enqueued job ID.',
);
await expect(
createAndEnqueueJob(type, payload, testOrgId),
).rejects.toThrow('Failed to get enqueued job ID.');
});
it('should throw error when enqueue returns undefined', async () => {
@ -168,9 +170,9 @@ describe('GitHub Webhook Utils', () => {
body: 'Test',
};
await expect(createAndEnqueueJob(type, payload)).rejects.toThrow(
'Failed to get enqueued job ID.',
);
await expect(
createAndEnqueueJob(type, payload, testOrgId),
).rejects.toThrow('Failed to get enqueued job ID.');
});
it('should handle different job types', async () => {
@ -189,12 +191,13 @@ describe('GitHub Webhook Utils', () => {
commentUrl: 'https://github.com/test/repo/issues/456#issuecomment-789',
};
const result = await createAndEnqueueJob(type, payload);
const result = await createAndEnqueueJob(type, payload, testOrgId);
expect(mockEnqueue).toHaveBeenCalledWith({
jobId: mockJob.id,
type,
payload,
orgId: testOrgId,
});
expect(result).toEqual({
jobId: mockJob.id,
@ -213,7 +216,7 @@ describe('GitHub Webhook Utils', () => {
body: 'Test',
};
await createAndEnqueueJob(type, payload);
await createAndEnqueueJob(type, payload, testOrgId);
expect(consoleSpy).toHaveBeenCalledWith(
`🔗 Enqueued ${type} job (id: ${mockJob.id}) ->`,
@ -236,9 +239,9 @@ describe('GitHub Webhook Utils', () => {
body: 'Test',
};
await expect(createAndEnqueueJob(type, payload)).rejects.toThrow(
'Database connection failed',
);
await expect(
createAndEnqueueJob(type, payload, testOrgId),
).rejects.toThrow('Database connection failed');
});
it('should handle enqueue service errors', async () => {
@ -252,9 +255,9 @@ describe('GitHub Webhook Utils', () => {
body: 'Test',
};
await expect(createAndEnqueueJob(type, payload)).rejects.toThrow(
'Queue service unavailable',
);
await expect(
createAndEnqueueJob(type, payload, testOrgId),
).rejects.toThrow('Queue service unavailable');
});
});
@ -362,6 +365,7 @@ describe('GitHub Webhook Utils', () => {
// Test job creation in a realistic webhook scenario.
const mockJob = { id: 456 };
const mockEnqueuedJob = { id: 'job-456' };
const testOrgId = 'test-org-456';
mockDb.insert.mockReturnValue({
values: vi.fn().mockReturnValue({
@ -371,12 +375,16 @@ describe('GitHub Webhook Utils', () => {
mockEnqueue.mockResolvedValue(mockEnqueuedJob);
// Create job after successful verification.
const result = await createAndEnqueueJob('github.issue.fix', {
repo: 'test/repo',
issue: 123,
title: 'Test issue',
body: 'Test issue body',
});
const result = await createAndEnqueueJob(
'github.issue.fix',
{
repo: 'test/repo',
issue: 123,
title: 'Test issue',
body: 'Test issue body',
},
testOrgId,
);
expect(result).toEqual({
jobId: mockJob.id,

View file

@ -1,10 +1,12 @@
import { createHmac } from 'crypto';
import { or, eq } from 'drizzle-orm';
import {
type JobType,
type JobPayload,
db,
cloudJobs,
orgs,
} from '@roo-code-cloud/db/server';
import { enqueue } from '@/lib';
@ -25,17 +27,38 @@ export function verifySignature(
export async function createAndEnqueueJob<T extends JobType>(
type: T,
payload: JobPayload<T>,
orgId?: string,
): Promise<{ jobId: number; enqueuedJobId: string }> {
// @TODO: Require `orgId` to be specified.
const organizationId =
orgId ||
(
await db
.select({ id: orgs.id })
.from(orgs)
.where(or(eq(orgs.name, 'Roo Code'), eq(orgs.name, 'Roo Code / Dev')))
.limit(1)
)[0]?.id;
if (!organizationId) {
throw new Error('Organization ID is required for job creation.');
}
const [job] = await db
.insert(cloudJobs)
.values({ type, payload, status: 'pending' })
.values({ type, payload, status: 'pending', orgId: organizationId })
.returning();
if (!job) {
throw new Error('Failed to create `cloudJobs` record.');
}
const enqueuedJob = await enqueue({ jobId: job.id, type, payload });
const enqueuedJob = await enqueue({
jobId: job.id,
type,
payload,
orgId: organizationId,
});
console.log(`🔗 Enqueued ${type} job (id: ${job.id}) ->`, payload);
if (!enqueuedJob.id) {

View file

@ -1,298 +0,0 @@
import {
command,
run,
string,
number,
option,
subcommands,
optional,
} from 'cmd-ts';
import type { JobPayload } from '@roo-code-cloud/db';
import {
fixGitHubIssue,
processIssueComment,
processPullRequestComment,
} from '@/lib/jobs';
import { runTask } from '@/lib/runTask';
const fixIssueCommand = command({
name: 'fix-issue',
description: 'Fix a GitHub issue',
args: {
repo: option({
type: string,
long: 'repo',
short: 'r',
description: 'Repository name (e.g., owner/repo)',
}),
issue: option({
type: number,
long: 'issue',
short: 'i',
description: 'Issue number',
}),
title: option({
type: string,
long: 'title',
short: 't',
description: 'Issue title',
}),
body: option({
type: string,
long: 'body',
short: 'b',
description: 'Issue body/description',
}),
labels: option({
type: optional(string),
long: 'labels',
short: 'l',
description: 'Issue labels (comma-separated: -l "bug,enhancement")',
}),
},
handler: async ({ repo, issue, title, body, labels }) => {
try {
console.log('🔧 Fixing GitHub issue...');
const payload: JobPayload<'github.issue.fix'> = {
repo,
issue,
title,
body,
labels: labels ? labels.split(',').map((l) => l.trim()) : [],
};
const result = await fixGitHubIssue(payload);
console.log('✅ Issue fix completed successfully!', result);
process.exit(0);
} catch (error) {
console.error('❌ Error fixing issue:', error);
process.exit(1);
}
},
});
const respondIssueCommentCommand = command({
name: 'respond-issue-comment',
description: 'Respond to a GitHub issue comment',
args: {
repo: option({
type: string,
long: 'repo',
short: 'r',
description: 'Repository name (e.g., owner/repo)',
}),
issueNumber: option({
type: number,
long: 'issue-number',
description: 'Issue number',
}),
issueTitle: option({
type: string,
long: 'issue-title',
description: 'Issue title',
}),
issueBody: option({
type: string,
long: 'issue-body',
description: 'Issue body',
}),
commentId: option({
type: number,
long: 'comment-id',
description: 'Comment ID',
}),
commentBody: option({
type: string,
long: 'comment-body',
description: 'Comment body',
}),
commentAuthor: option({
type: string,
long: 'comment-author',
description: 'Comment author',
}),
commentUrl: option({
type: string,
long: 'comment-url',
description: 'Comment URL',
}),
},
handler: async (args) => {
try {
console.log('💬 Responding to GitHub issue comment...');
const payload: JobPayload<'github.issue.comment.respond'> = {
repo: args.repo,
issueNumber: args.issueNumber,
issueTitle: args.issueTitle,
issueBody: args.issueBody,
commentId: args.commentId,
commentBody: args.commentBody,
commentAuthor: args.commentAuthor,
commentUrl: args.commentUrl,
};
const result = await processIssueComment(payload);
console.log('✅ Issue comment response completed successfully!', result);
process.exit(0);
} catch (error) {
console.error('❌ Error responding to issue comment:', error);
process.exit(1);
}
},
});
const respondPrCommentCommand = command({
name: 'respond-pr-comment',
description: 'Respond to a GitHub PR comment',
args: {
repo: option({
type: string,
long: 'repo',
short: 'r',
description: 'Repository name (e.g., owner/repo)',
}),
prNumber: option({
type: number,
long: 'pr-number',
description: 'PR number',
}),
prTitle: option({
type: string,
long: 'pr-title',
description: 'PR title',
}),
prBody: option({
type: string,
long: 'pr-body',
description: 'PR body',
}),
prBranch: option({
type: string,
long: 'pr-branch',
description: 'PR branch',
}),
baseRef: option({
type: string,
long: 'base-ref',
description: 'Base reference',
}),
commentId: option({
type: number,
long: 'comment-id',
description: 'Comment ID',
}),
commentBody: option({
type: string,
long: 'comment-body',
description: 'Comment body',
}),
commentAuthor: option({
type: string,
long: 'comment-author',
description: 'Comment author',
}),
commentType: option({
type: string,
long: 'comment-type',
description: 'Comment type (issue_comment or review_comment)',
}),
commentUrl: option({
type: string,
long: 'comment-url',
description: 'Comment URL',
}),
},
handler: async (args) => {
try {
console.log('💬 Responding to GitHub PR comment...');
if (
args.commentType !== 'issue_comment' &&
args.commentType !== 'review_comment'
) {
throw new Error(
'Comment type must be either "issue_comment" or "review_comment"',
);
}
const payload: JobPayload<'github.pr.comment.respond'> = {
repo: args.repo,
prNumber: args.prNumber,
prTitle: args.prTitle,
prBody: args.prBody,
prBranch: args.prBranch,
baseRef: args.baseRef,
commentId: args.commentId,
commentBody: args.commentBody,
commentAuthor: args.commentAuthor,
commentType: args.commentType as 'issue_comment' | 'review_comment',
commentUrl: args.commentUrl,
};
const result = await processPullRequestComment(payload);
console.log('✅ PR comment response completed successfully!', result);
process.exit(0);
} catch (error) {
console.error('❌ Error responding to PR comment:', error);
process.exit(1);
}
},
});
const promptCommand = command({
name: 'prompt',
description: 'Prompt',
args: {
text: option({
type: string,
long: 'text',
description: 'Text to test',
}),
mode: option({
type: string,
long: 'mode',
description: 'Mode to use: code, ask, architect',
}),
workspacePath: option({
type: string,
long: 'workspace-path',
description: 'Workspace path',
}),
},
handler: async ({ text, mode, workspacePath }) => {
if (!['code', 'ask', 'architect'].includes(mode)) {
throw new Error('Invalid mode');
}
await runTask({
jobType: 'test.prompt',
jobPayload: { text },
prompt: text,
notify: false,
workspacePath,
settings: { mode },
});
process.exit(0);
},
});
// Example:
// pnpm --filter @roo-code-cloud/roomote cli prompt --text "What time is it?" --mode ask --workspace-path ~/Documents/Roo-Code
const app = subcommands({
name: 'roomote-cli',
description: 'Roomote CLI - Run jobs directly from the command line',
cmds: {
[fixIssueCommand.name]: fixIssueCommand,
[respondIssueCommentCommand.name]: respondIssueCommentCommand,
[respondPrCommentCommand.name]: respondPrCommentCommand,
[promptCommand.name]: promptCommand,
},
});
run(app, process.argv.slice(2));

View file

@ -10,18 +10,20 @@ import {
type UpdateCloudJob,
db,
cloudJobs,
orgSettings,
} from '@roo-code-cloud/db/server';
import { fixGitHubIssue } from './jobs/fixGitHubIssue';
import { processPullRequestComment } from './jobs/processPullRequestComment';
import { processIssueComment } from './jobs/processIssueComment';
import { processSlackMention } from './jobs/processSlackMention';
import { processGeneralTask } from './jobs/processGeneralTask';
import { SlackNotifier } from './slack';
const slack = new SlackNotifier();
export async function processJob<T extends JobType>({
data: { type, payload, jobId },
data: { type, payload, jobId, orgId },
...job
}: Job<JobParams<T>>) {
console.log(
@ -29,63 +31,37 @@ export async function processJob<T extends JobType>({
);
try {
const onTaskStarted = createOnTaskStartedCallback(jobId);
const mode = await getConfiguredMode(orgId, type);
console.log(
`[${job.name} | ${job.id}] Using mode '${mode}' for task type '${type}'`,
);
let result: unknown;
switch (type) {
case 'github.issue.fix':
result = await fixGitHubIssue(
payload as JobPayload<'github.issue.fix'>,
{
onTaskStarted: (
slackThreadTs: string | null | undefined,
_rooTaskId: string,
) =>
updateJobStatus(
jobId,
'processing',
undefined,
undefined,
slackThreadTs,
),
},
{ onTaskStarted },
mode,
);
break;
case 'github.issue.comment.respond':
result = await processIssueComment(
payload as JobPayload<'github.issue.comment.respond'>,
{
onTaskStarted: (
slackThreadTs: string | null | undefined,
_rooTaskId: string,
) =>
updateJobStatus(
jobId,
'processing',
undefined,
undefined,
slackThreadTs,
),
},
{ onTaskStarted },
mode,
);
break;
case 'github.pr.comment.respond':
result = await processPullRequestComment(
payload as JobPayload<'github.pr.comment.respond'>,
{
onTaskStarted: (
slackThreadTs: string | null | undefined,
_rooTaskId: string,
) =>
updateJobStatus(
jobId,
'processing',
undefined,
undefined,
slackThreadTs,
),
},
{ onTaskStarted },
mode,
);
break;
@ -94,17 +70,7 @@ export async function processJob<T extends JobType>({
const { channel, thread_ts } = jobPayload;
result = await processSlackMention(jobPayload, {
onTaskStarted: (
slackThreadTs: string | null | undefined,
_rooTaskId: string,
) =>
updateJobStatus(
jobId,
'processing',
undefined,
undefined,
slackThreadTs,
),
onTaskStarted,
onTaskMessage: async (message: ClineMessage) => {
console.log(`onTaskMessage (${channel}, ${thread_ts}) ->`, message);
@ -120,6 +86,13 @@ export async function processJob<T extends JobType>({
break;
}
case 'general.task': {
const jobPayload = payload as JobPayload<'general.task'>;
result = await processGeneralTask(jobPayload, { onTaskStarted });
break;
}
default:
throw new Error(`Unknown job type: ${type}`);
}
@ -134,6 +107,11 @@ export async function processJob<T extends JobType>({
}
}
function createOnTaskStartedCallback(jobId: number) {
return (slackThreadTs: string | null | undefined, _rooTaskId: string) =>
updateJobStatus(jobId, 'processing', undefined, undefined, slackThreadTs);
}
async function updateJobStatus(
jobId: number,
status: JobStatus,
@ -163,3 +141,49 @@ async function updateJobStatus(
await db.update(cloudJobs).set(values).where(eq(cloudJobs.id, jobId));
}
const DEFAULT_MODE_MAPPINGS: Record<JobType, string> = {
'github.issue.fix': 'issue-fixer',
'github.issue.comment.respond': 'ask',
'github.pr.comment.respond': 'ask',
'slack.app.mention': 'code',
'general.task': 'code',
};
async function getConfiguredMode(
orgId: string,
taskType: JobType,
): Promise<string> {
try {
const settings = await db
.select()
.from(orgSettings)
.where(eq(orgSettings.orgId, orgId))
.limit(1);
if (settings.length === 0) {
return DEFAULT_MODE_MAPPINGS[taskType];
}
const orgSetting = settings[0];
if (!orgSetting) {
return DEFAULT_MODE_MAPPINGS[taskType];
}
const cloudSettings = orgSetting.cloudSettings as Record<string, unknown>;
const roomoteModeMappings = cloudSettings?.roomoteModeMappings as
| Partial<Record<JobType, string>>
| undefined;
if (roomoteModeMappings && roomoteModeMappings[taskType]) {
return roomoteModeMappings[taskType];
}
return DEFAULT_MODE_MAPPINGS[taskType];
} catch (error) {
console.error('Error fetching organization settings:', error);
return DEFAULT_MODE_MAPPINGS[taskType];
}
}

View file

@ -1,20 +1,16 @@
import type { JobType, JobPayload } from '@roo-code-cloud/db';
import type { JobPayload } from '@roo-code-cloud/db';
import { runTask, type RunTaskCallbacks } from '../runTask';
import { CRITICAL_COMMAND_RESTRICTIONS, MAIN_BRANCH_PROTECTION } from '../promptConstants';
const jobType: JobType = 'github.issue.fix';
type FixGitHubIssueJobPayload = JobPayload<'github.issue.fix'>;
import {
CRITICAL_COMMAND_RESTRICTIONS,
MAIN_BRANCH_PROTECTION,
} from '../promptConstants';
export async function fixGitHubIssue(
jobPayload: FixGitHubIssueJobPayload,
jobPayload: JobPayload<'github.issue.fix'>,
callbacks?: RunTaskCallbacks,
): Promise<{
repo: string;
issue: number;
result: unknown;
}> {
mode?: string,
) {
const prompt = `
Fix the following GitHub issue:
@ -29,13 +25,11 @@ ${MAIN_BRANCH_PROTECTION}
const { repo, issue } = jobPayload;
const result = await runTask({
jobType,
jobType: 'github.issue.fix',
jobPayload,
prompt,
callbacks,
settings: {
mode: 'issue-fixer',
},
mode,
});
return { repo, issue, result };

View file

@ -2,3 +2,4 @@ export { fixGitHubIssue } from './fixGitHubIssue';
export { processIssueComment } from './processIssueComment';
export { processPullRequestComment } from './processPullRequestComment';
export { processSlackMention } from './processSlackMention';
export { processGeneralTask } from './processGeneralTask';

View file

@ -0,0 +1,33 @@
import type { JobPayload } from '@roo-code-cloud/db';
import { runTask, type RunTaskCallbacks } from '../runTask';
export async function processGeneralTask(
jobPayload: JobPayload<'general.task'>,
callbacks?: RunTaskCallbacks,
mode?: string,
) {
// Add your workspace root to .env.local to override the default
// workspace root that our containers use.
const workspaceRoot = process.env.WORKSPACE_ROOT || '/roo/repos';
const workspacePath = `${workspaceRoot}/${jobPayload.repo.split('/')[1]}`;
const prompt = `
Repository: ${jobPayload.repo}
Task: ${jobPayload.description}
Please complete this task and create a pull request with your changes when finished.
`;
const result = await runTask({
jobType: 'general.task',
jobPayload,
prompt,
callbacks,
notify: false,
workspacePath,
mode,
});
return { result };
}

View file

@ -1,21 +1,16 @@
import type { JobType, JobPayload } from '@roo-code-cloud/db';
import type { JobPayload } from '@roo-code-cloud/db';
import { runTask, type RunTaskCallbacks } from '../runTask';
import { CRITICAL_COMMAND_RESTRICTIONS, MAIN_BRANCH_PROTECTION } from '../promptConstants';
const jobType: JobType = 'github.issue.comment.respond';
type ProcessIssueCommentJobPayload = JobPayload<'github.issue.comment.respond'>;
import {
CRITICAL_COMMAND_RESTRICTIONS,
MAIN_BRANCH_PROTECTION,
} from '../promptConstants';
export async function processIssueComment(
jobPayload: ProcessIssueCommentJobPayload,
jobPayload: JobPayload<'github.issue.comment.respond'>,
callbacks?: RunTaskCallbacks,
): Promise<{
repo: string;
issueNumber: number;
commentId: number;
result: unknown;
}> {
mode?: string,
) {
const prompt = `
Respond to the following GitHub Issue comment:
@ -56,10 +51,11 @@ gh api repos/${jobPayload.repo}/issues/${jobPayload.issueNumber}/comments --meth
const { repo, issueNumber, commentId } = jobPayload;
const result = await runTask({
jobType,
jobType: 'github.issue.comment.respond',
jobPayload,
prompt,
callbacks,
mode,
});
return { repo, issueNumber, commentId, result };

View file

@ -1,22 +1,16 @@
import type { JobType, JobPayload } from '@roo-code-cloud/db';
import type { JobPayload } from '@roo-code-cloud/db';
import { runTask, type RunTaskCallbacks } from '../runTask';
import { CRITICAL_COMMAND_RESTRICTIONS, MAIN_BRANCH_PROTECTION } from '../promptConstants';
const jobType: JobType = 'github.pr.comment.respond';
type ProcessPullRequestCommentJobPayload =
JobPayload<'github.pr.comment.respond'>;
import {
CRITICAL_COMMAND_RESTRICTIONS,
MAIN_BRANCH_PROTECTION,
} from '../promptConstants';
export async function processPullRequestComment(
jobPayload: ProcessPullRequestCommentJobPayload,
jobPayload: JobPayload<'github.pr.comment.respond'>,
callbacks?: RunTaskCallbacks,
): Promise<{
repo: string;
prNumber: number;
commentId: number;
result: unknown;
}> {
mode?: string,
) {
const prompt = `
Process the following GitHub Pull Request comment:
@ -70,10 +64,11 @@ Do not create a new pull request - work directly on the existing PR branch.
const { repo, prNumber, commentId } = jobPayload;
const result = await runTask({
jobType,
jobType: 'github.pr.comment.respond',
jobPayload,
prompt,
callbacks,
mode,
});
return { repo, prNumber, commentId, result };

View file

@ -1,23 +1,19 @@
import type { JobType, JobPayload } from '@roo-code-cloud/db';
import type { JobPayload } from '@roo-code-cloud/db';
import { runTask, type RunTaskCallbacks } from '../runTask';
import { CRITICAL_COMMAND_RESTRICTIONS, GIT_WORKFLOW_INSTRUCTIONS, MAIN_BRANCH_PROTECTION } from '../promptConstants';
const jobType: JobType = 'slack.app.mention';
type ProcessSlackMentionJobPayload = JobPayload<'slack.app.mention'>;
import {
CRITICAL_COMMAND_RESTRICTIONS,
GIT_WORKFLOW_INSTRUCTIONS,
MAIN_BRANCH_PROTECTION,
} from '../promptConstants';
export async function processSlackMention(
jobPayload: ProcessSlackMentionJobPayload,
jobPayload: JobPayload<'slack.app.mention'>,
callbacks?: RunTaskCallbacks,
): Promise<{
channel: string;
user: string;
result: unknown;
}> {
mode?: string,
) {
const { text: originalPrompt, channel, user } = jobPayload;
// Create a structured prompt following the same pattern as other roomote triggers
const prompt = `
Process the following Slack mention request:
@ -50,12 +46,13 @@ ${GIT_WORKFLOW_INSTRUCTIONS}
: `${workspaceRoot}/${jobPayload.workspace}`;
const result = await runTask({
jobType,
jobType: 'slack.app.mention',
jobPayload,
prompt,
callbacks,
notify: false,
workspacePath,
mode,
});
return { channel, user, result };

View file

@ -1,7 +1,3 @@
/**
* Shared constants for roomote job prompts
*/
export const CRITICAL_COMMAND_RESTRICTIONS = `
CRITICAL COMMAND RESTRICTIONS:
- NEVER execute long-running commands like starting servers (npm run dev, npm start, python -m http.server, etc.)
@ -21,4 +17,4 @@ This ensures all changes are properly tracked and can be reviewed before merging
export const MAIN_BRANCH_PROTECTION = `
NEVER commit directly to the main branch. Always create a feature branch for your changes.
`.trim();
`.trim();

View file

@ -65,6 +65,7 @@ type RunTaskOptions<T extends JobType> = {
notify?: boolean;
workspacePath?: string;
settings?: RooCodeSettings;
mode?: string;
};
export const runTask = async <T extends JobType>({
@ -78,6 +79,7 @@ export const runTask = async <T extends JobType>({
notify = true,
workspacePath = '/roo/repos/Roo-Code',
settings = {},
mode,
}: RunTaskOptions<T>) => {
const ipcSocketPath = path.resolve(
os.tmpdir(),
@ -90,15 +92,15 @@ export const runTask = async <T extends JobType>({
let envVars = `ROO_CODE_IPC_SOCKET_PATH=${ipcSocketPath}`;
// Create JWT token if we have jobId and userId
// Create JWT token if we have jobId and userId.
if (jobId && userId) {
try {
// Get user's org info
const user = await db
.select()
.from(users)
.where(eq(users.id, userId))
.limit(1);
const orgId = user[0]?.orgId || null;
const token = await createJobToken(
@ -109,11 +111,10 @@ export const runTask = async <T extends JobType>({
);
envVars += ` ROO_CODE_CLOUD_TOKEN=${token}`;
envVars += ` ROO_CODE_CLOUD_ORG_SETTINGS=${Buffer.from(JSON.stringify(ORGANIZATION_DEFAULT)).toString('base64')}`;
} catch (error) {
logger?.error('Failed to create job token:', error);
// Continue without token - job will fall back to no auth
// Continue without token - job will fall back to no auth.
}
}
@ -131,9 +132,10 @@ export const runTask = async <T extends JobType>({
logger.info(codeCommand);
// Pull latest changes from git before opening VSCode
// Pull latest changes from git before opening VSCode.
try {
const repoConfig = getRepoConfigByPath(workspacePath);
if (repoConfig) {
logger.info(`Pulling latest changes for repository: ${repoConfig.name}`);
await gitPullRepoFromConfig(repoConfig, logger);
@ -148,7 +150,7 @@ export const runTask = async <T extends JobType>({
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Failed to pull git changes: ${errorMessage}`);
// Continue with task execution even if git pull fails
// Continue with task execution even if git pull fails.
logger.info('Continuing with task execution despite git pull failure');
}
@ -305,6 +307,7 @@ export const runTask = async <T extends JobType>({
openRouterApiKey: process.env.OPENROUTER_API_KEY,
lastShownAnnouncementId: 'jun-17-2025-3-21',
...settings,
mode,
},
text: prompt,
},

View file

@ -135,6 +135,21 @@ export class SlackNotifier {
],
});
}
case 'general.task': {
const payload = jobPayload as JobPayload<'general.task'>;
return await this.postMessage({
text: `🚀 Task Started`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `🚀 *Task Started*\nWorking on general task in <https://github.com/${payload.repo}|${payload.repo}>\n*Task:* ${payload.description.slice(0, 200)}${payload.description.length > 200 ? '...' : ''}`,
},
},
],
});
}
default:
throw new Error(`Unknown job type: ${jobType}`);
}

View file

@ -9,3 +9,5 @@ NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL=/authorized
NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL=/authorized
NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/authorized
NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/authorized
ROOMOTE_API_URL=http://localhost:3001

View file

@ -1,2 +1,4 @@
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_Y2xlcmsucm9vY29kZS5jb20k
NEXT_PUBLIC_CLERK_FRONTEND_API=https://clerk.roocode.com
ROOMOTE_API_URL=https://roomote-api.fly.dev

View file

@ -23,6 +23,7 @@
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",

View file

@ -8,11 +8,6 @@ import { Env } from '@roo-code-cloud/env';
import { type AuthResult, type ApiAuthResult, isOrgRole } from '@/types';
import { logger } from '@/lib/server';
import { validateJobToken } from '@roo-code-cloud/job-auth';
// import {
// type AgentTokenPayload,
// validateAgentToken,
// } from '@/lib/server/agent-auth';
// import { updateAgentUsage } from '@/actions/agents';
export async function authorize(): Promise<AuthResult> {
const { userId, orgId, orgRole } = await auth();
@ -21,7 +16,6 @@ export async function authorize(): Promise<AuthResult> {
return { success: false, error: 'Unauthorized: User required' };
}
// Personal context is valid - no org required
if (!orgId) {
return {
success: true,
@ -41,19 +35,17 @@ export async function authorize(): Promise<AuthResult> {
};
}
/**
* Validates authentication and authorization for API endpoints.
*/
export async function authorizeApi(
request: NextRequest,
): Promise<ApiAuthResult> {
const authHeader = request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
return authorize(); // Fall back to Clerk auth
return authorize();
}
const token = authHeader.slice(7);
if (!token) {
return {
success: false,
@ -61,9 +53,9 @@ export async function authorizeApi(
};
}
// Try job token first
try {
const jobContext = await validateJobToken(token);
return {
success: true,
userType: 'job',
@ -72,17 +64,10 @@ export async function authorizeApi(
jobId: jobContext.jobId,
};
} catch {
// If job token validation fails, try agent token
// (existing agent token code would go here)
// If both fail, fall back to Clerk
return authorize();
}
}
/**
* Validates authentication and authorization for analytics functions.
*/
export async function authorizeAnalytics({
requestedOrgId,
requestedUserId,
@ -100,9 +85,7 @@ export async function authorizeAnalytics({
throw new Error('Unauthorized: User required');
}
// Handle personal context
if (!authOrgId && !requestedOrgId) {
// Personal context - user can only access their own data
if (requestedUserId && requestedUserId !== authUserId) {
throw new Error(
'Unauthorized: Personal users can only access their own data',
@ -117,17 +100,14 @@ export async function authorizeAnalytics({
};
}
// Ensure user is authenticated and belongs to the organization
if (!authOrgId || !authUserId || authOrgId !== requestedOrgId) {
throw new Error('Unauthorized: Invalid organization access');
}
// Check if admin access is required
if (requireAdmin && orgRole !== 'org:admin') {
throw new Error('Unauthorized: Administrator access required');
}
// If user is not an admin and trying to access data other than their own
if (
orgRole !== 'org:admin' &&
requestedUserId &&
@ -136,8 +116,6 @@ export async function authorizeAnalytics({
throw new Error('Unauthorized: Members can only access their own data');
}
// For non-admin users, force userId filter to their own ID
// Unless allowCrossUserAccess is true and we're checking task sharing permissions
const effectiveUserId =
orgRole !== 'org:admin' && !allowCrossUserAccess
? authUserId

View file

@ -0,0 +1,238 @@
'use server';
import { z } from 'zod';
import { desc, eq, and, getTableColumns } from 'drizzle-orm';
import {
type CloudJob,
type CreateJob,
type User,
db,
cloudJobs,
orgSettings,
users,
createJobSchema,
} from '@roo-code-cloud/db/server';
import { authorizeRoomotes } from '@/lib/roomotes';
export type CloudJobWithUser = CloudJob & {
user?: User | null;
};
export async function getCloudSettings(): Promise<
| { success: true; data: Record<string, unknown> }
| { success: false; error: string }
> {
try {
const authResult = await authorizeRoomotes();
if (!authResult.success) {
return {
success: false,
error: authResult.error || 'Roomotes feature not enabled',
};
}
if (!authResult.orgId) {
return { success: false, error: 'Organization membership required' };
}
const { orgId } = authResult;
const [row] = await db
.select()
.from(orgSettings)
.where(eq(orgSettings.orgId, orgId))
.limit(1);
return { success: true, data: row?.cloudSettings ?? {} };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
}
export async function updateCloudSettings(
newSettings: Record<string, unknown>,
): Promise<{ success: true } | { success: false; error: string }> {
try {
const authResult = await authorizeRoomotes();
if (!authResult.success) {
return {
success: false,
error: authResult.error || 'Roomotes feature not enabled',
};
}
if (authResult.orgRole !== 'org:admin') {
return { success: false, error: 'Admin access required' };
}
const { orgId } = authResult;
const [row] = await db
.select()
.from(orgSettings)
.where(eq(orgSettings.orgId, orgId))
.limit(1);
const cloudSettings = {
...(row?.cloudSettings || {}),
...newSettings,
};
if (row) {
await db
.update(orgSettings)
.set({ cloudSettings, updatedAt: new Date() })
.where(eq(orgSettings.orgId, orgId));
} else {
await db.insert(orgSettings).values({ orgId, cloudSettings });
}
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
}
export async function fetchRoomoteJobs(
userId?: string | null,
): Promise<
| { success: true; jobs: CloudJobWithUser[] }
| { success: false; error: string }
> {
try {
const authResult = await authorizeRoomotes();
if (!authResult.success) {
return {
success: false,
error: authResult.error || 'Roomotes feature not enabled',
};
}
if (!authResult.orgId) {
return { success: false, error: 'Organization membership required' };
}
const { orgId, orgRole } = authResult;
const conditions = [eq(cloudJobs.orgId, orgId)];
if (!userId && orgRole !== 'org:admin') {
userId = authResult.userId;
}
if (userId) {
conditions.push(eq(cloudJobs.userId, userId));
}
const jobs = await db
.select({
...getTableColumns(cloudJobs),
user: getTableColumns(users),
})
.from(cloudJobs)
.leftJoin(users, eq(cloudJobs.userId, users.id))
.where(conditions.length > 1 ? and(...conditions) : conditions[0])
.orderBy(desc(cloudJobs.createdAt))
.limit(50);
return { success: true, jobs };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
}
export async function createRoomoteJob(
data: CreateJob,
): Promise<
| { success: true; jobId: number; enqueuedJobId: string }
| { success: false; error: string }
> {
try {
const authResult = await authorizeRoomotes();
if (!authResult.success) {
return {
success: false,
error: authResult.error || 'Roomotes feature not enabled',
};
}
if (!authResult.orgId) {
return { success: false, error: 'Organization membership required' };
}
const { orgId, userId } = authResult;
const response = await fetch(`${process.env.ROOMOTE_API_URL}/api/jobs`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(createJobSchema.parse({ ...data, orgId, userId })),
});
if (!response.ok) {
console.error(response);
const error =
(await response.json().catch(() => undefined)) ??
`HTTP ${response.status}: ${response.statusText}`;
return { success: false, error };
}
const { jobId, enqueuedJobId } = await response.json();
return { success: true, jobId, enqueuedJobId };
} catch (error) {
console.error(error);
if (error instanceof z.ZodError) {
return {
success: false,
error: `Validation error: ${error.errors.map((e) => e.message).join(', ')}`,
};
}
if (error instanceof TypeError) {
const { cause } = error;
if (
typeof cause === 'object' &&
cause &&
'code' in cause &&
cause.code === 'ECONNREFUSED'
) {
return {
success: false,
error: `Connection refused. Please check if the Roomote API server is running.`,
};
}
}
if (error instanceof Error) {
return {
success: false,
error: error.message,
};
}
return {
success: false,
error: 'An unknown error occurred.',
};
}
}

View file

@ -0,0 +1,155 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Loader2 } from 'lucide-react';
import type { JobType } from '@roo-code-cloud/db';
import { QueryKey } from '@/types';
import { getCloudSettings, updateCloudSettings } from '@/actions/roomote';
import {
Button,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui';
import { TASK_TYPES } from './constants';
type ConfigureTasksProps = {
onSuccess?: () => void;
};
export function ConfigureTasks({ onSuccess }: ConfigureTasksProps) {
const [localChanges, setLocalChanges] = useState<
Partial<Record<JobType, string>>
>({});
const [selectedTaskType, setSelectedTaskType] = useState<JobType | undefined>(
TASK_TYPES[0]?.key,
);
const query = useQuery({
queryKey: [QueryKey.GetCloudSettings],
queryFn: getCloudSettings,
select: (data) =>
data.success && data.data?.roomoteModeMappings
? (data.data.roomoteModeMappings as Partial<Record<JobType, string>>)
: {},
});
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: updateCloudSettings,
onSuccess: (result) => {
if (result.success) {
toast.success('Task configuration successfully saved.');
setLocalChanges({});
queryClient.invalidateQueries({
queryKey: [QueryKey.GetCloudSettings],
});
onSuccess?.();
} else {
toast.error(result.error);
}
},
onError: () => toast.error('An unexpected error occurred.'),
});
const modes = { ...(query.data || {}), ...localChanges };
return query.isLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="animate-spin" />
</div>
) : (
<div className="space-y-4">
<div className="space-y-4">
<Select
value={selectedTaskType}
onValueChange={(value) => setSelectedTaskType(value as JobType)}
>
<SelectTrigger>
<SelectValue placeholder="Select Task Type" />
</SelectTrigger>
<SelectContent>
{TASK_TYPES.map((taskType) => (
<SelectItem key={taskType.key} value={taskType.key}>
{taskType.label}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedTaskType && (
<TaskConfiguration
jobType={selectedTaskType}
modes={modes}
onChange={(value) =>
setLocalChanges((prev) => ({
...prev,
[selectedTaskType]: value,
}))
}
disabled={mutation.isPending}
/>
)}
</div>
{Object.keys(localChanges).length > 0 && (
<div className="flex justify-end">
<Button
onClick={() => mutation.mutate({ roomoteModeMappings: modes })}
disabled={mutation.isPending}
size="sm"
>
{mutation.isPending ? <Loader2 className="animate-spin" /> : 'Save'}
</Button>
</div>
)}
</div>
);
}
interface TaskConfigurationProps {
jobType: JobType;
modes: Partial<Record<JobType, string>>;
onChange: (value: string) => void;
disabled: boolean;
}
function TaskConfiguration({
jobType,
modes,
onChange,
disabled,
}: TaskConfigurationProps) {
const taskType = TASK_TYPES.find((t) => t.key === jobType);
return taskType ? (
<div className="border rounded p-4 space-y-4">
<div>
<label htmlFor={taskType.key} className="text-sm font-medium block">
{taskType.label}
</label>
<p className="text-xs text-muted-foreground mt-1">
{taskType.description}
</p>
</div>
<Input
id={taskType.key}
type="text"
value={modes[jobType] || ''}
onChange={(e) => onChange(e.target.value)}
placeholder="Mode"
disabled={disabled}
/>
</div>
) : null;
}

View file

@ -0,0 +1,141 @@
'use client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAuth } from '@clerk/nextjs';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Loader2 } from 'lucide-react';
import { type JobType } from '@roo-code-cloud/db';
import { QueryKey } from '@/types';
import { createRoomoteJob } from '@/actions/roomote';
import {
Button,
Form,
FormDescription,
FormItem,
FormLabel,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectGroup,
SelectItem,
} from '@/components/ui';
import { type FormData, formSchema } from './types';
import { TASK_TYPES } from './constants';
import {
GitHubIssueFixFields,
GitHubIssueCommentFields,
GitHubPRCommentFields,
GeneralTaskFields,
} from './components';
type CreateTaskProps = {
onSuccess?: () => void;
};
export function CreateTask({ onSuccess }: CreateTaskProps) {
const { orgId, userId } = useAuth();
const [selectedJobType, setSelectedJobType] =
useState<JobType>('general.task');
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
orgId: orgId || '',
userId: userId || '',
type: 'general.task',
},
});
const onJobTypeChange = (jobType: JobType) => {
setSelectedJobType(jobType);
form.reset();
form.setValue('type', jobType);
};
const queryClient = useQueryClient();
const onSubmit = async (data: FormData) => {
try {
const result = await createRoomoteJob({ ...data });
if (result.success) {
toast.success(`Job created successfully (job_id: ${result.jobId}).`);
onJobTypeChange(selectedJobType);
queryClient.invalidateQueries({
queryKey: [QueryKey.FetchRoomoteJobs],
});
onSuccess?.();
} else {
toast.error(result.error);
}
} catch (_error) {
toast.error('An unexpected error occurred.');
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormItem>
<FormLabel>Task Type</FormLabel>
<Select onValueChange={onJobTypeChange}>
<SelectTrigger>
<SelectValue
placeholder={
selectedJobType
? TASK_TYPES.find((type) => type.key === selectedJobType)
?.label
: 'Select'
}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{TASK_TYPES.map(({ key, label }) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
Choose the type of task you want to create.
</FormDescription>
</FormItem>
{selectedJobType === 'general.task' && <GeneralTaskFields />}
{selectedJobType === 'github.issue.fix' && <GitHubIssueFixFields />}
{selectedJobType === 'github.issue.comment.respond' && (
<GitHubIssueCommentFields />
)}
{selectedJobType === 'github.pr.comment.respond' && (
<GitHubPRCommentFields />
)}
{selectedJobType && (
<div className="flex justify-end">
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<Loader2 className="animate-spin" />
) : (
'Create Task'
)}
</Button>
</div>
)}
</form>
</Form>
);
}

View file

@ -0,0 +1,267 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { formatDistanceToNow } from 'date-fns';
import { Loader2, RefreshCw, Plus, Cog } from 'lucide-react';
import { QueryKey } from '@/types';
import { cn } from '@/lib';
import {
Button,
Badge,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui';
import { fetchRoomoteJobs } from '@/actions/roomote';
import { STATUSES, LABELS } from './constants';
import { getJobTitle } from './utils';
import { CreateTask } from './CreateTask';
import { ConfigureTasks } from './ConfigureTasks';
interface JobsProps {
userId?: string;
}
export function Jobs({ userId }: JobsProps) {
const [modal, setModal] = useState<'create' | 'configure'>();
const query = useQuery({
queryKey: [QueryKey.FetchRoomoteJobs, userId],
queryFn: () => fetchRoomoteJobs(userId),
refetchInterval: 120_000,
});
const jobs = query.data?.success ? query.data.jobs : [];
const error =
query.data?.success === false ? query.data.error : query.error?.message;
const lastUpdated = query.dataUpdatedAt
? new Date(query.dataUpdatedAt)
: null;
if (query.isLoading && jobs.length === 0) {
return (
<div className="flex items-center justify-center py-4">
<Loader2 className="animate-spin" />
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center gap-4 py-4">
<div className="text-destructive">{error}</div>
<Button onClick={() => query.refetch()} variant="outline">
<RefreshCw />
Retry
</Button>
</div>
);
}
return (
<>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<div className="text-xl font-bold tracking-tight">
Roomote Tasks
</div>
<div className="text-sm text-muted-foreground">
Create and manage automated tasks using Roomote.
</div>
</div>
<div className="flex flex-col gap-1">
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setModal('create')}
>
<Plus />
Create Task
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setModal('configure')}
>
<Cog />
Configure Tasks
</Button>
<Button
onClick={() => query.refetch()}
variant="outline"
size="sm"
>
<RefreshCw
className={cn({ 'animate-spin': query.isLoading })}
/>
Refresh
</Button>
</div>
{lastUpdated && (
<div className="text-sm text-muted-foreground text-right">
Updated {formatDistanceToNow(lastUpdated, { addSuffix: true })}
</div>
)}
</div>
</div>
{jobs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No jobs found. Create your first job using the form above.
</div>
) : (
<div className="border rounded-lg overflow-hidden">
<div className="bg-muted/50 px-4 py-3 border-b">
<div className="grid grid-cols-12 gap-4 text-sm font-medium text-muted-foreground">
<div className="col-span-1">ID</div>
<div className="col-span-2">Type</div>
<div className="col-span-3">Title</div>
<div className="col-span-2">Triggered By</div>
<div className="col-span-2">Status</div>
<div className="col-span-1">Created</div>
<div className="col-span-1">Duration</div>
</div>
</div>
<div className="divide-y">
{jobs.map((job) => {
const { label, variant, icon: Icon } = STATUSES[job.status];
const duration =
job.completedAt && job.startedAt
? Math.round(
(new Date(job.completedAt).getTime() -
new Date(job.startedAt).getTime()) /
1000,
)
: null;
return (
<div
key={job.id}
className="px-4 py-3 hover:bg-muted/30 transition-colors"
>
<div className="grid grid-cols-12 gap-4 items-center text-sm">
<div className="col-span-1 font-mono text-xs">
#{job.id}
</div>
<div className="col-span-2">
<span className="text-xs bg-muted px-2 py-1 rounded">
{LABELS[job.type]}
</span>
</div>
<div className="col-span-3">
<div className="truncate" title={getJobTitle(job)}>
{getJobTitle(job)}
</div>
{job.error && (
<div
className="text-xs text-red-600 truncate mt-1"
title={job.error}
>
Error: {job.error}
</div>
)}
</div>
<div className="col-span-2">
{job.user ? (
<div className="text-xs">
<div
className="font-medium truncate"
title={job.user.name}
>
{job.user.name}
</div>
<div
className="text-muted-foreground truncate"
title={job.user.email}
>
{job.user.email}
</div>
</div>
) : (
<div className="text-xs text-muted-foreground">
Unknown user
</div>
)}
</div>
<div className="col-span-2">
<Badge
variant={variant}
className="flex items-center gap-1 w-fit"
>
<Icon
className={`h-3 w-3 ${job.status === 'processing' ? 'animate-spin' : ''}`}
/>
{label}
</Badge>
</div>
<div className="col-span-1 text-muted-foreground text-xs">
{formatDistanceToNow(new Date(job.createdAt), {
addSuffix: true,
})}
</div>
<div className="col-span-1 text-muted-foreground text-xs">
{duration ? `${duration}s` : '-'}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
<Dialog
open={modal === 'create'}
onOpenChange={() => setModal(undefined)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Task</DialogTitle>
<DialogDescription>
Select a task type and fill in the required information to create
a new Roomote task.
</DialogDescription>
</DialogHeader>
<CreateTask onSuccess={() => setModal(undefined)} />
</DialogContent>
</Dialog>
<Dialog
open={modal === 'configure'}
onOpenChange={() => setModal(undefined)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Configure Tasks</DialogTitle>
<DialogDescription>
Configure which mode Roomote should use for each type of task.
Enter the mode slug (e.g., &quot;code&quot;,
&quot;architect&quot;, &quot;ask&quot;, &quot;debug&quot;,
&quot;orchestrator&quot;, &quot;designer&quot;,
&quot;security-review&quot;).
</DialogDescription>
</DialogHeader>
<ConfigureTasks onSuccess={() => setModal(undefined)} />
</DialogContent>
</Dialog>
</>
);
}

View file

@ -0,0 +1,65 @@
import { useFormContext } from 'react-hook-form';
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '@/components/ui';
import type { FormData } from '../types';
export function GeneralTaskFields() {
const { control } = useFormContext<FormData>();
return (
<>
<FormField
control={control}
name="payload.repo"
render={({ field }) => (
<FormItem>
<FormLabel>Repository</FormLabel>
<FormControl>
<Input
placeholder="owner/repository"
{...field}
value={field.value || ''}
/>
</FormControl>
<FormDescription>
GitHub repository in format: owner/repository
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="payload.description"
render={({ field }) => (
<FormItem>
<FormLabel>Task Description</FormLabel>
<FormControl>
<textarea
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Describe what you want Roomote to do..."
{...field}
value={field.value || ''}
/>
</FormControl>
<FormDescription>
Provide a detailed description of the task you want Roomote to
perform
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
);
}

View file

@ -0,0 +1,150 @@
import { useFormContext } from 'react-hook-form';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '@/components/ui';
import type { FormData } from '../types';
export function GitHubIssueCommentFields() {
const { control } = useFormContext<FormData>();
return (
<>
<FormField
control={control}
name="payload.repo"
render={({ field }) => (
<FormItem>
<FormLabel>Repository</FormLabel>
<FormControl>
<Input placeholder="owner/repository" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="payload.issueNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Issue Number</FormLabel>
<FormControl>
<Input
type="number"
placeholder="123"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="payload.issueTitle"
render={({ field }) => (
<FormItem>
<FormLabel>Issue Title</FormLabel>
<FormControl>
<Input placeholder="Issue title" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="payload.issueBody"
render={({ field }) => (
<FormItem>
<FormLabel>Issue Body</FormLabel>
<FormControl>
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Issue description..."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="payload.commentId"
render={({ field }) => (
<FormItem>
<FormLabel>Comment ID</FormLabel>
<FormControl>
<Input
type="number"
placeholder="456"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="payload.commentBody"
render={({ field }) => (
<FormItem>
<FormLabel>Comment Body</FormLabel>
<FormControl>
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Comment content..."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="payload.commentAuthor"
render={({ field }) => (
<FormItem>
<FormLabel>Comment Author</FormLabel>
<FormControl>
<Input placeholder="username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="payload.commentUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Comment URL</FormLabel>
<FormControl>
<Input placeholder="https://github.com/..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
}

View file

@ -0,0 +1,89 @@
import { useFormContext } from 'react-hook-form';
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '@/components/ui';
import type { FormData } from '../types';
export function GitHubIssueFixFields() {
const { control } = useFormContext<FormData>();
return (
<>
<FormField
control={control}
name="payload.repo"
render={({ field }) => (
<FormItem>
<FormLabel>Repository</FormLabel>
<FormControl>
<Input placeholder="owner/repository" {...field} />
</FormControl>
<FormDescription>
GitHub repository in format: owner/repository
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="payload.issue"
render={({ field }) => (
<FormItem>
<FormLabel>Issue Number</FormLabel>
<FormControl>
<Input
type="number"
placeholder="123"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="payload.title"
render={({ field }) => (
<FormItem>
<FormLabel>Issue Title</FormLabel>
<FormControl>
<Input placeholder="Issue title" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="payload.body"
render={({ field }) => (
<FormItem>
<FormLabel>Issue Body</FormLabel>
<FormControl>
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Issue description..."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
}

View file

@ -0,0 +1,215 @@
import { useFormContext } from 'react-hook-form';
import { ChevronDown } from 'lucide-react';
import {
Button,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui';
import type { FormData } from '../types';
export function GitHubPRCommentFields() {
const { control } = useFormContext<FormData>();
return (
<>
<FormField
control={control}
name="payload.repo"
render={({ field }) => (
<FormItem>
<FormLabel>Repository</FormLabel>
<FormControl>
<Input placeholder="owner/repository" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="payload.prNumber"
render={({ field }) => (
<FormItem>
<FormLabel>PR Number</FormLabel>
<FormControl>
<Input
type="number"
placeholder="123"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="payload.prTitle"
render={({ field }) => (
<FormItem>
<FormLabel>PR Title</FormLabel>
<FormControl>
<Input placeholder="Pull request title" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="payload.prBody"
render={({ field }) => (
<FormItem>
<FormLabel>PR Body</FormLabel>
<FormControl>
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Pull request description..."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="payload.prBranch"
render={({ field }) => (
<FormItem>
<FormLabel>PR Branch</FormLabel>
<FormControl>
<Input placeholder="feature-branch" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="payload.baseRef"
render={({ field }) => (
<FormItem>
<FormLabel>Base Ref</FormLabel>
<FormControl>
<Input placeholder="main" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="payload.commentId"
render={({ field }) => (
<FormItem>
<FormLabel>Comment ID</FormLabel>
<FormControl>
<Input
type="number"
placeholder="456"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="payload.commentBody"
render={({ field }) => (
<FormItem>
<FormLabel>Comment Body</FormLabel>
<FormControl>
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Comment content..."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="payload.commentAuthor"
render={({ field }) => (
<FormItem>
<FormLabel>Comment Author</FormLabel>
<FormControl>
<Input placeholder="username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="payload.commentType"
render={({ field }) => (
<FormItem>
<FormLabel>Comment Type</FormLabel>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="w-full justify-between">
{field.value || 'Select comment type'}
<ChevronDown className="h-4 w-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-full">
<DropdownMenuItem
onClick={() => field.onChange('issue_comment')}
>
Issue Comment
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => field.onChange('review_comment')}
>
Review Comment
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="payload.commentUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Comment URL</FormLabel>
<FormControl>
<Input placeholder="https://github.com/..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
}

View file

@ -0,0 +1,4 @@
export { GitHubIssueFixFields } from './GitHubIssueFixFields';
export { GitHubIssueCommentFields } from './GitHubIssueCommentFields';
export { GitHubPRCommentFields } from './GitHubPRCommentFields';
export { GeneralTaskFields } from './GeneralTaskFields';

View file

@ -0,0 +1,61 @@
import { Clock, CheckCircle, XCircle, Loader2 } from 'lucide-react';
import type { JobType } from '@roo-code-cloud/db';
export const TASK_TYPES: {
key: JobType;
label: string;
description: string;
}[] = [
{
key: 'general.task',
label: 'General Task',
description: 'Default mode used for general tasks.',
},
{
key: 'github.issue.fix',
label: 'Fix GitHub Issue',
description: 'Default mode used when fixing GitHub issues.',
},
{
key: 'github.issue.comment.respond',
label: 'Respond to Issue Comment',
description: 'Default mode used when responding to issue comments.',
},
{
key: 'github.pr.comment.respond',
label: 'Respond to PR Comment',
description: 'Default mode used when responding to pull request comments.',
},
] as const;
export const STATUSES = {
pending: {
label: 'Pending',
variant: 'secondary' as const,
icon: Clock,
},
processing: {
label: 'Processing',
variant: 'default' as const,
icon: Loader2,
},
completed: {
label: 'Completed',
variant: 'default' as const,
icon: CheckCircle,
},
failed: {
label: 'Failed',
variant: 'destructive' as const,
icon: XCircle,
},
};
export const LABELS: Record<JobType, string> = {
'github.issue.fix': 'Fix GitHub Issue',
'github.issue.comment.respond': 'Respond to Issue Comment',
'github.pr.comment.respond': 'Respond to PR Comment',
'slack.app.mention': 'Respond to Slack Mention',
'general.task': 'General Task',
};

View file

@ -0,0 +1,28 @@
import { redirect } from 'next/navigation';
import { authorize } from '@/actions/auth';
import { authorizeRoomotes } from '@/lib/roomotes';
import { Jobs } from './Jobs';
export default async function Page() {
const authResult = await authorize();
if (!authResult.success) {
redirect('/select-org');
}
const { success } = await authorizeRoomotes();
if (!success) {
redirect('/usage?error=roomotes_not_enabled');
}
return (
<Jobs
userId={
authResult.orgRole === 'org:admin' ? undefined : authResult.userId
}
/>
);
}

View file

@ -0,0 +1,19 @@
import { z } from 'zod';
import {
githubIssueFixSchema,
githubIssueCommentSchema,
githubPullRequestCommentSchema,
slackAppMentionSchema,
generalTaskSchema,
} from '@roo-code-cloud/db';
export const formSchema = z.discriminatedUnion('type', [
githubIssueFixSchema,
githubIssueCommentSchema,
githubPullRequestCommentSchema,
slackAppMentionSchema,
generalTaskSchema,
]);
export type FormData = z.infer<typeof formSchema>;

View file

@ -0,0 +1,58 @@
import type { CloudJobWithUser } from '@/actions/roomote';
export function getJobTitle(job: CloudJobWithUser): string {
switch (job.type) {
case 'github.issue.fix': {
const payload = job.payload as {
repo: string;
issue: number;
title: string;
body: string;
labels?: string[];
};
return `Fix issue #${payload.issue} in ${payload.repo}`;
}
case 'github.issue.comment.respond': {
const payload = job.payload as {
repo: string;
issueNumber: number;
issueTitle: string;
issueBody: string;
commentId: number;
commentBody: string;
commentAuthor: string;
commentUrl: string;
};
return `Respond to comment in ${payload.repo}#${payload.issueNumber}`;
}
case 'github.pr.comment.respond': {
const payload = job.payload as {
repo: string;
prNumber: number;
prTitle: string;
prBody: string;
prBranch: string;
baseRef: string;
commentId: number;
commentBody: string;
commentAuthor: string;
commentType: 'issue_comment' | 'review_comment';
commentUrl: string;
};
return `Respond to PR comment in ${payload.repo}#${payload.prNumber}`;
}
case 'general.task': {
const payload = job.payload as {
repo: string;
description: string;
};
return `${payload.repo}: ${payload.description.substring(0, 50)}${payload.description.length > 50 ? '...' : ''}`;
}
default:
return 'Unknown job type';
}
}

View file

@ -1,8 +1,9 @@
'use client';
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { useUser } from '@clerk/nextjs';
import { X, AlertCircle } from 'lucide-react';
import type { TaskWithUser } from '@/actions/analytics';
import { Button } from '@/components/ui';
@ -20,14 +21,20 @@ import { UsageFilters } from './UsageFilters';
type UsageProps = {
userRole?: 'admin' | 'member';
currentUserId?: string | null;
error?: string;
};
export const Usage = ({ userRole = 'admin', currentUserId }: UsageProps) => {
export const Usage = ({
userRole = 'admin',
currentUserId,
error,
}: UsageProps) => {
const { isSignedIn } = useUser();
const t = useTranslations('Analytics');
const [viewMode, setViewMode] = useState<ViewMode>('tasks');
const [filters, setFilters] = useState<Filter[]>([]);
const [task, setTask] = useState<TaskWithUser | null>(null);
const [showError, setShowError] = useState(!!error);
const onAddFilter = useCallback((newFilter: Filter) => {
setFilters((currentFilters) => {
@ -40,6 +47,22 @@ export const Usage = ({ userRole = 'admin', currentUserId }: UsageProps) => {
setViewMode('tasks');
}, []);
useEffect(() => {
if (error) {
const timer = setTimeout(() => setShowError(false), 10_000);
return () => clearTimeout(timer);
}
}, [error]);
const getErrorMessage = (errorCode: string) => {
switch (errorCode) {
case 'roomotes_not_enabled':
return 'The Roomotes feature is not enabled for your account. Please contact your administrator.';
default:
return 'An error occurred. Please try again.';
}
};
const onRemoveFilter = useCallback((filterToRemove: Filter) => {
setFilters((currentFilters) =>
currentFilters.filter(
@ -69,6 +92,24 @@ export const Usage = ({ userRole = 'admin', currentUserId }: UsageProps) => {
return (
<>
<div className="flex flex-col gap-3 sm:gap-4 lg:gap-6">
{showError && error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-red-600 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-red-800 text-sm font-medium">
{getErrorMessage(error)}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowError(false)}
className="text-red-600 hover:text-red-800 p-1 h-auto"
>
<X className="h-4 w-4" />
</Button>
</div>
)}
<UsageCard
userRole={userRole}
currentUserId={currentUserId}

View file

@ -2,15 +2,21 @@ import { authorize } from '@/actions/auth';
import { Usage } from './Usage';
export default async function Page() {
type PageProps = {
searchParams: Promise<{ error?: string }>;
};
export default async function Page({ searchParams }: PageProps) {
const authResult = await authorize();
const orgRole = authResult.success ? authResult.orgRole : null;
const userId = authResult.success ? authResult.userId : null;
const params = await searchParams;
return (
<Usage
userRole={orgRole === 'org:admin' ? 'admin' : 'member'}
currentUserId={userId}
error={params.error}
/>
);
}

View file

@ -22,6 +22,13 @@ vi.mock('@/actions/organizationSettings', () => ({
getOrganizationSettings: vi.fn(),
}));
vi.mock('@/lib/server/analytics', () => ({
analytics: {
query: vi.fn(),
insert: vi.fn(),
},
}));
vi.mock('@/lib/providers', () => ({
PROVIDERS: {
anthropic: {

View file

@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { useRoomotes } from '@/hooks/useRoomotes';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/ecosystem';
import { Section } from './Section';
@ -20,6 +21,7 @@ const tabValues = [
'/providers',
'/settings',
'/org',
'/roomote',
'/hidden',
] as const;
@ -35,6 +37,7 @@ export const NavbarMenu = ({
const router = useRouter();
const pathname = usePathname();
const [tabValue, setTabValue] = useState<TabValue | undefined>(undefined);
const roomotes = useRoomotes();
useEffect(() => {
setTabValue(isTabValue(pathname) ? pathname : '/hidden');
@ -66,6 +69,9 @@ export const NavbarMenu = ({
<TabsTrigger value="/org">Organization</TabsTrigger>
</>
)}
{roomotes.isEnabled && (
<TabsTrigger value="/roomote">Roomote</TabsTrigger>
)}
<TabsTrigger value="/hidden" className="hidden" />
</TabsList>
</Tabs>

View file

@ -58,6 +58,7 @@ function DialogContent({
data-slot="dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
'max-h-[calc(100vh-2rem)] overflow-y-auto',
className,
)}
{...props}

View file

@ -13,6 +13,7 @@ export * from './pagination';
export * from './PaginationControls';
export * from './CursorPaginationControls';
export * from './popover';
export * from './select';
export * from './separator';
export * from './skeleton';
export * from './slider';

View file

@ -0,0 +1,185 @@
'use client';
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default';
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = 'popper',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View file

@ -0,0 +1,23 @@
'use client';
import { useOrganization } from '@clerk/nextjs';
import { useQuery } from '@tanstack/react-query';
import { QueryKey } from '@/types';
import { isRoomoteEnabled } from '@/lib/roomotes';
export function useRoomotes() {
const { organization, isLoaded } = useOrganization();
const { data } = useQuery({
queryKey: [QueryKey.IsRoomoteEnabled, organization?.id],
queryFn: () =>
organization?.id ? isRoomoteEnabled(organization.id) : false,
enabled: isLoaded && !!organization?.id,
staleTime: 5 * 60 * 1000,
});
return {
isEnabled: data === true,
};
}

View file

@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { clerkClient } from '@clerk/nextjs/server';
import { isRoomoteEnabled, authorizeRoomotes } from '../roomotes';
// Mock Clerk
vi.mock('@clerk/nextjs/server', () => ({
clerkClient: vi.fn(),
}));
// Mock auth action
vi.mock('@/actions/auth', () => ({
authorize: vi.fn(),
}));
describe('roomotes', () => {
const mockClerkClient = {
organizations: {
getOrganization: vi.fn(),
},
};
beforeEach(() => {
vi.clearAllMocks();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(clerkClient as any).mockResolvedValue(mockClerkClient);
});
describe('isRoomoteEnabled', () => {
it('should return true when roomotes_enabled is true in organization private metadata', async () => {
mockClerkClient.organizations.getOrganization.mockResolvedValue({
privateMetadata: {
roomotes_enabled: true,
},
});
const result = await isRoomoteEnabled('org_123');
expect(result).toBe(true);
});
it('should return false when roomotes_enabled is false in organization private metadata', async () => {
mockClerkClient.organizations.getOrganization.mockResolvedValue({
privateMetadata: {
roomotes_enabled: false,
},
});
const result = await isRoomoteEnabled('org_123');
expect(result).toBe(false);
});
it('should return false when roomotes_enabled is not set', async () => {
mockClerkClient.organizations.getOrganization.mockResolvedValue({
privateMetadata: {},
});
const result = await isRoomoteEnabled('org_123');
expect(result).toBe(false);
});
it('should return false when privateMetadata is not set', async () => {
mockClerkClient.organizations.getOrganization.mockResolvedValue({});
const result = await isRoomoteEnabled('org_123');
expect(result).toBe(false);
});
it('should return false when Clerk API throws an error', async () => {
mockClerkClient.organizations.getOrganization.mockRejectedValue(
new Error('API Error'),
);
const result = await isRoomoteEnabled('org_123');
expect(result).toBe(false);
});
});
describe('authorizeRoomotes', () => {
it('should return success when user is authorized and feature is enabled', async () => {
const { authorize } = await import('@/actions/auth');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(authorize as any).mockResolvedValue({
success: true,
userId: 'user_123',
orgId: 'org_456',
orgRole: 'org:admin',
});
mockClerkClient.organizations.getOrganization.mockResolvedValue({
privateMetadata: {
roomotes_enabled: true,
},
});
const result = await authorizeRoomotes();
expect(result).toEqual({
success: true,
userId: 'user_123',
orgId: 'org_456',
orgRole: 'org:admin',
});
});
it('should return error when feature is not enabled', async () => {
const { authorize } = await import('@/actions/auth');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(authorize as any).mockResolvedValue({
success: true,
userId: 'user_123',
orgId: 'org_456',
orgRole: 'org:admin',
});
mockClerkClient.organizations.getOrganization.mockResolvedValue({
privateMetadata: {
roomotes_enabled: false,
},
});
const result = await authorizeRoomotes();
expect(result).toEqual({
success: false,
error: 'Roomotes feature is not enabled for your organization',
});
});
it('should return error when user is not authorized', async () => {
const { authorize } = await import('@/actions/auth');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(authorize as any).mockResolvedValue({
success: false,
error: 'Unauthorized',
});
const result = await authorizeRoomotes();
expect(result).toEqual({
success: false,
error: 'Unauthorized',
});
});
it('should return error when user has no organization', async () => {
const { authorize } = await import('@/actions/auth');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(authorize as any).mockResolvedValue({
success: true,
userId: 'user_123',
orgId: null,
orgRole: null,
});
const result = await authorizeRoomotes();
expect(result).toEqual({
success: false,
error: 'Roomotes feature is only available for organization accounts',
});
});
});
});

View file

@ -0,0 +1,43 @@
'use server';
import { clerkClient } from '@clerk/nextjs/server';
import { authorize } from '@/actions/auth';
export async function isRoomoteEnabled(organizationId: string) {
try {
const client = await clerkClient();
const organization = await client.organizations.getOrganization({
organizationId,
});
return organization.privateMetadata?.roomotes_enabled === true;
} catch (error) {
console.error('Error checking roomotes feature flag:', error);
return false;
}
}
export async function authorizeRoomotes() {
const authResult = await authorize();
if (!authResult.success) {
return authResult;
}
const { userId, orgId, orgRole } = authResult;
if (!orgId) {
return {
success: false,
error: 'Roomotes feature is only available for organization accounts',
};
}
return (await isRoomoteEnabled(orgId))
? { success: true, userId, orgId, orgRole }
: {
success: false,
error: 'Roomotes feature is not enabled for your organization',
};
}

View file

@ -2,4 +2,7 @@ export enum QueryKey {
GetOrganizationSettings = 'GetOrganizationSettings',
GetDynamicRouterModels = 'GetDynamicRouterModels',
CanShareTask = 'canShareTask',
IsRoomoteEnabled = 'isRoomoteEnabled',
GetCloudSettings = 'getCloudSettings',
FetchRoomoteJobs = 'fetchRoomoteJobs',
}

View file

@ -1,7 +1,7 @@
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
# Deploy with: `fly deploy --config fly.roomote-worker.toml --build-arg GH_TOKEN=$(npx dotenvx get GH_TOKEN -f .env.production)`
# Test with: `fly machine run $(fly releases --image -a roomote-worker -j 2>/dev/null | jq -r '.[0].ImageRef') --vm-size performance-16x --restart on-fail --rm --shell --command "pnpm cli:production prompt --text 'Tell me a pirate joke.' --mode code --workspace-path /roo/repos/Roo-Code-Cloud" -a roomote-worker`
# Test with: `fly machine run $(fly releases --image -a roomote-worker -j 2>/dev/null | jq -r '.[0].ImageRef') --vm-size performance-16x --restart on-fail --rm --shell -a roomote-worker`
app = "roomote-worker"
primary_region = "sjc" # See `fly platform regions`

View file

@ -0,0 +1,5 @@
DELETE FROM "cloud_jobs";--> statement-breakpoint
ALTER TABLE "cloud_jobs" ADD COLUMN "organization_id" text NOT NULL;--> statement-breakpoint
ALTER TABLE "cloud_jobs" ADD CONSTRAINT "cloud_jobs_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "cloud_jobs_user_id_idx" ON "cloud_jobs" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "cloud_jobs_user_org_idx" ON "cloud_jobs" USING btree ("user_id","organization_id");

File diff suppressed because it is too large Load diff

View file

@ -22,6 +22,13 @@
"when": 1751397021767,
"tag": "0002_slippery_alex_power",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1751564211996,
"tag": "0003_good_sauron",
"breakpoints": true
}
]
}

View file

@ -247,7 +247,7 @@ export const agentsRelations = relations(agents, ({ one, many }) => ({
}));
/**
* agent_request_logs
* agentRequestLogs
*/
export const agentRequestLogs = pgTable(
@ -293,22 +293,26 @@ export const agentRequestLogsRelations = relations(
* cloudJobs
*/
export const cloudJobs = pgTable('cloud_jobs', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
type: text('type').notNull().$type<JobType>(),
status: text('status').notNull().default('pending').$type<JobStatus>(),
payload: jsonb('payload').notNull().$type<JobPayload>(),
result: jsonb('result'),
error: text('error'),
slackThreadTs: text('slack_thread_ts'),
userId: text('user_id').references(() => users.id),
startedAt: timestamp('started_at'),
completedAt: timestamp('completed_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
});
export type CloudJob = typeof cloudJobs.$inferSelect;
export type InsertCloudJob = typeof cloudJobs.$inferInsert;
export type UpdateCloudJob = Partial<Omit<CloudJob, 'id' | 'createdAt'>>;
export const cloudJobs = pgTable(
'cloud_jobs',
{
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
type: text('type').notNull().$type<JobType>(),
orgId: text('organization_id')
.notNull()
.references(() => orgs.id),
userId: text('user_id').references(() => users.id),
status: text('status').notNull().default('pending').$type<JobStatus>(),
payload: jsonb('payload').notNull().$type<JobPayload>(),
result: jsonb('result'),
error: text('error'),
slackThreadTs: text('slack_thread_ts'),
startedAt: timestamp('started_at'),
completedAt: timestamp('completed_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => [
index('cloud_jobs_user_id_idx').on(table.userId),
index('cloud_jobs_user_org_idx').on(table.userId, table.orgId),
],
);

View file

@ -20,9 +20,6 @@ export {
agentRequestLogs,
agentRequestLogsRelations,
cloudJobs,
type CloudJob,
type InsertCloudJob,
type UpdateCloudJob,
} from './schema';
export * from './queries';

View file

@ -8,6 +8,7 @@ import type {
taskShares,
agents,
agentRequestLogs,
cloudJobs,
} from './schema';
type Generated = 'id' | 'createdAt' | 'updatedAt';
@ -78,67 +79,98 @@ export type CreateRequestLog = Omit<
Generated
>;
/**
* cloudJobs
*/
export type CloudJob = typeof cloudJobs.$inferSelect;
export type InsertCloudJob = typeof cloudJobs.$inferInsert;
export type UpdateCloudJob = Partial<Omit<CloudJob, 'id' | 'createdAt'>>;
/**
* CreateJob
*/
export const githubIssueFixSchema = z.object({
type: z.literal('github.issue.fix'),
orgId: z.string(),
userId: z.string(),
payload: z.object({
repo: z.string(),
issue: z.number(),
title: z.string(),
body: z.string(),
labels: z.array(z.string()).optional(),
}),
});
export const githubIssueCommentSchema = z.object({
type: z.literal('github.issue.comment.respond'),
orgId: z.string(),
userId: z.string(),
payload: z.object({
repo: z.string(),
issueNumber: z.number(),
issueTitle: z.string(),
issueBody: z.string(),
commentId: z.number(),
commentBody: z.string(),
commentAuthor: z.string(),
commentUrl: z.string(),
}),
});
export const githubPullRequestCommentSchema = z.object({
type: z.literal('github.pr.comment.respond'),
orgId: z.string(),
userId: z.string(),
payload: z.object({
repo: z.string(),
prNumber: z.number(),
prTitle: z.string(),
prBody: z.string(),
prBranch: z.string(),
baseRef: z.string(),
commentId: z.number(),
commentBody: z.string(),
commentAuthor: z.string(),
commentType: z.enum(['issue_comment', 'review_comment']),
commentUrl: z.string(),
}),
});
export const slackAppMentionSchema = z.object({
type: z.literal('slack.app.mention'),
orgId: z.string(),
userId: z.string(),
payload: z.object({
channel: z.string(),
user: z.string(),
text: z.string(),
ts: z.string(),
thread_ts: z.string().optional(),
workspace: z.string(),
}),
});
export const generalTaskSchema = z.object({
type: z.literal('general.task'),
orgId: z.string(),
userId: z.string(),
payload: z.object({
repo: z.string(),
description: z.string(),
}),
});
export const createJobSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('github.issue.fix'),
payload: z.object({
repo: z.string(),
issue: z.number(),
title: z.string(),
body: z.string(),
labels: z.array(z.string()).optional(),
}),
}),
z.object({
type: z.literal('github.issue.comment.respond'),
payload: z.object({
repo: z.string(),
issueNumber: z.number(),
issueTitle: z.string(),
issueBody: z.string(),
commentId: z.number(),
commentBody: z.string(),
commentAuthor: z.string(),
commentUrl: z.string(),
}),
}),
z.object({
type: z.literal('github.pr.comment.respond'),
payload: z.object({
repo: z.string(),
prNumber: z.number(),
prTitle: z.string(),
prBody: z.string(),
prBranch: z.string(),
baseRef: z.string(),
commentId: z.number(),
commentBody: z.string(),
commentAuthor: z.string(),
commentType: z.enum(['issue_comment', 'review_comment']),
commentUrl: z.string(),
}),
}),
z.object({
type: z.literal('slack.app.mention'),
payload: z.object({
channel: z.string(),
user: z.string(),
text: z.string(),
ts: z.string(),
thread_ts: z.string().optional(),
workspace: z.string(),
}),
}),
z.object({
type: z.literal('test.prompt'),
payload: z.object({
text: z.string(),
}),
}),
githubIssueFixSchema,
githubIssueCommentSchema,
githubPullRequestCommentSchema,
slackAppMentionSchema,
generalTaskSchema,
]);
export type CreateJob = z.infer<typeof createJobSchema>;
@ -160,5 +192,6 @@ export type JobPayload<T extends JobType = JobType> = JobTypes[T];
export type JobParams<T extends JobType> = {
jobId: number;
type: T;
orgId: string;
payload: JobPayload<T>;
};

65
pnpm-lock.yaml generated
View file

@ -57,9 +57,6 @@ importers:
bullmq:
specifier: ^5.37.0
version: 5.54.3
cmd-ts:
specifier: ^0.13.0
version: 0.13.0
drizzle-orm:
specifier: ^0.44.2
version: 0.44.2(@electric-sql/pglite@0.3.0)(@libsql/client-wasm@0.15.5)(@opentelemetry/api@1.9.0)(@types/pg@8.15.2)(pg@8.15.6)(postgres@3.4.7)
@ -185,6 +182,9 @@ importers:
'@radix-ui/react-popover':
specifier: ^1.1.14
version: 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-select':
specifier: ^2.2.5
version: 2.2.5(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-separator':
specifier: ^1.1.7
version: 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -1988,6 +1988,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-select@2.2.5':
resolution: {integrity: sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-separator@1.1.7':
resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==}
peerDependencies:
@ -3400,9 +3413,6 @@ packages:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'}
cmd-ts@0.13.0:
resolution: {integrity: sha512-nsnxf6wNIM/JAS7T/x/1JmbEsjH0a8tezXqqpaL0O6+eV0/aDEnRxwjxpu0VzDdRcaC1ixGSbRlUuf/IU59I4g==}
cmdk@1.1.1:
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
peerDependencies:
@ -3774,9 +3784,6 @@ packages:
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
@ -8438,6 +8445,35 @@ snapshots:
'@types/react': 19.1.6
'@types/react-dom': 19.1.6(@types/react@19.1.6)
'@radix-ui/react-select@2.2.5(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.2
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.6)(react@19.1.0)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
aria-hidden: 1.2.6
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react-remove-scroll: 2.7.0(@types/react@19.1.6)(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.6
'@types/react-dom': 19.1.6(@types/react@19.1.6)
'@radix-ui/react-separator@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -10002,15 +10038,6 @@ snapshots:
cluster-key-slot@1.1.2: {}
cmd-ts@0.13.0:
dependencies:
chalk: 4.1.2
debug: 4.4.1
didyoumean: 1.2.2
strip-ansi: 6.0.1
transitivePeerDependencies:
- supports-color
cmdk@1.1.1(@types/react-dom@19.1.6(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.6)(react@19.1.0)
@ -10397,8 +10424,6 @@ snapshots:
dependencies:
dequal: 2.0.3
didyoumean@1.2.2: {}
doctrine@2.1.0:
dependencies:
esutils: 2.0.3