diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 601173e2..3296bf8b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,6 @@ name: Release on: - push: - tags: - - 'v*' workflow_dispatch: inputs: release_tag: @@ -20,13 +17,13 @@ permissions: contents: write concurrency: - group: release-${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || inputs.release_tag || github.ref }} + group: release-${{ inputs.release_tag || github.ref }} cancel-in-progress: false env: - IS_RELEASE_BUILD: ${{ startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' }} - RELEASE_TAG: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || inputs.release_tag }} - PUBLISH_RELEASE: ${{ startsWith(github.ref, 'refs/tags/v') || inputs.publish_release }} + IS_RELEASE_BUILD: ${{ github.event_name == 'workflow_dispatch' }} + RELEASE_TAG: ${{ inputs.release_tag }} + PUBLISH_RELEASE: ${{ inputs.publish_release }} jobs: build: @@ -81,8 +78,8 @@ jobs: - name: Build app env: NODE_OPTIONS: '--max-old-space-size=8192' - SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }} - SENTRY_AUTH_TOKEN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_AUTH_TOKEN || '' }} + SENTRY_DSN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_DSN || '' }} + SENTRY_AUTH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_AUTH_TOKEN || '' }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} run: pnpm build @@ -102,8 +99,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - TAG="${GITHUB_REF#refs/tags/}" - TAG="${RELEASE_TAG:-$TAG}" + TAG="${RELEASE_TAG}" existing_is_draft="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft' 2>/dev/null || true)" if [ -n "$existing_is_draft" ]; then if [ "${PUBLISH_RELEASE}" != "true" ] && [ "$existing_is_draft" != "true" ]; then @@ -155,8 +151,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - TAG="${GITHUB_REF#refs/tags/}" - TAG="${RELEASE_TAG:-$TAG}" + TAG="${RELEASE_TAG}" existing_is_draft="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft' 2>/dev/null || true)" if [ -n "$existing_is_draft" ]; then if [ "${PUBLISH_RELEASE}" != "true" ] && [ "$existing_is_draft" != "true" ]; then @@ -184,8 +179,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - TAG="${GITHUB_REF#refs/tags/}" - TAG="${RELEASE_TAG:-$TAG}" + TAG="${RELEASE_TAG}" existing="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' 2>/dev/null || true)" missing=0 while IFS= read -r asset; do @@ -278,8 +272,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - TAG="${GITHUB_REF#refs/tags/}" - TAG="${RELEASE_TAG:-$TAG}" + TAG="${RELEASE_TAG}" for attempt in $(seq 1 12); do existing="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' 2>/dev/null || true)" all_found=1 @@ -377,8 +370,8 @@ jobs: - name: Build app (macOS ${{ matrix.arch }}) env: NODE_OPTIONS: '--max-old-space-size=8192' - SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }} - SENTRY_AUTH_TOKEN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_AUTH_TOKEN || '' }} + SENTRY_DSN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_DSN || '' }} + SENTRY_AUTH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_AUTH_TOKEN || '' }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} run: pnpm build @@ -494,8 +487,8 @@ jobs: - name: Build app (Windows) env: NODE_OPTIONS: '--max-old-space-size=8192' - SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }} - SENTRY_AUTH_TOKEN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_AUTH_TOKEN || '' }} + SENTRY_DSN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_DSN || '' }} + SENTRY_AUTH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_AUTH_TOKEN || '' }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} run: pnpm build @@ -614,8 +607,8 @@ jobs: - name: Build app (Linux) env: NODE_OPTIONS: '--max-old-space-size=8192' - SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }} - SENTRY_AUTH_TOKEN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_AUTH_TOKEN || '' }} + SENTRY_DSN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_DSN || '' }} + SENTRY_AUTH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && secrets.SENTRY_AUTH_TOKEN || '' }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} run: pnpm build @@ -642,6 +635,9 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: pnpm pack:linux --publish never + - name: Validate Linux installers + run: node ./scripts/electron-builder/verifyLinuxInstallers.cjs release + - name: Validate packaged bundle (Linux) run: node ./scripts/electron-builder/verifyBundle.cjs "release/linux-unpacked" linux x64 @@ -667,10 +663,10 @@ jobs: needs: [release-mac, release-win, release-linux] runs-on: ubuntu-latest timeout-minutes: 30 - if: ${{ startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' }} + if: ${{ github.event_name == 'workflow_dispatch' }} steps: - - name: Upload compatibility aliases for older updater clients + - name: Upload stable download aliases env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -681,7 +677,7 @@ jobs: TMP_DIR="$(mktemp -d)" trap 'rm -rf "$TMP_DIR"' EXIT - declare -A COMPATIBILITY_ALIASES=( + declare -A STABLE_ALIASES=( ["Agent.Teams.AI-arm64.dmg"]="Agent.Teams.AI-${VERSION}-arm64.dmg" ["Agent.Teams.AI-x64.dmg"]="Agent.Teams.AI-${VERSION}-x64.dmg" ["Agent.Teams.AI.Setup.exe"]="Agent.Teams.AI.Setup.${VERSION}.exe" @@ -689,18 +685,11 @@ jobs: ["agent-teams-ai-amd64.deb"]="agent-teams-ai_${VERSION}_amd64.deb" ["agent-teams-ai-x86_64.rpm"]="agent-teams-ai-${VERSION}.x86_64.rpm" ["agent-teams-ai.pacman"]="agent-teams-ai-${VERSION}.pacman" - ["Claude-Agent-Teams-UI-arm64.dmg"]="Agent.Teams.AI-${VERSION}-arm64.dmg" - ["Claude-Agent-Teams-UI-x64.dmg"]="Agent.Teams.AI-${VERSION}-x64.dmg" - ["Claude-Agent-Teams-UI-Setup.exe"]="Agent.Teams.AI.Setup.${VERSION}.exe" - ["Claude-Agent-Teams-UI.AppImage"]="Agent.Teams.AI-${VERSION}.AppImage" - ["Claude-Agent-Teams-UI-amd64.deb"]="agent-teams-ai_${VERSION}_amd64.deb" - ["Claude-Agent-Teams-UI-x86_64.rpm"]="agent-teams-ai-${VERSION}.x86_64.rpm" - ["Claude-Agent-Teams-UI.pacman"]="agent-teams-ai-${VERSION}.pacman" ) - for ALIAS_NAME in "${!COMPATIBILITY_ALIASES[@]}"; do - VERSIONED_NAME="${COMPATIBILITY_ALIASES[$ALIAS_NAME]}" - echo "Uploading compatibility alias: ${ALIAS_NAME} -> ${VERSIONED_NAME}" + for ALIAS_NAME in "${!STABLE_ALIASES[@]}"; do + VERSIONED_NAME="${STABLE_ALIASES[$ALIAS_NAME]}" + echo "Uploading stable alias: ${ALIAS_NAME} -> ${VERSIONED_NAME}" gh release download "${TAG}" \ --repo "$REPO" \ --pattern "${VERSIONED_NAME}" \ @@ -798,7 +787,7 @@ jobs: gh release upload "${TAG}" latest.yml latest-linux.yml latest-mac.yml --repo "${REPO}" --clobber - name: Publish release - if: ${{ startsWith(github.ref, 'refs/tags/v') || inputs.publish_release }} + if: ${{ inputs.publish_release }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | diff --git a/agent-teams-controller/src/internal/kanban.js b/agent-teams-controller/src/internal/kanban.js index d91912c5..83c13afc 100644 --- a/agent-teams-controller/src/internal/kanban.js +++ b/agent-teams-controller/src/internal/kanban.js @@ -36,6 +36,14 @@ function assertKanbanColumnAllowed(context, task, column, options = {}) { } if (column === 'approved') { + if (transition === 'manual_approve') { + if (reviewState !== 'none') { + throw new Error( + `Task ${label} is already in reviewState=${reviewState}; use review_approve instead` + ); + } + return; + } if (transition === 'approve_review') { if (reviewState !== 'review' && reviewState !== 'approved') { throw new Error(`Task ${label} must be in review before approval`); diff --git a/agent-teams-controller/src/internal/memberMessagingProtocol.js b/agent-teams-controller/src/internal/memberMessagingProtocol.js index 2d8da105..1f3466ef 100644 --- a/agent-teams-controller/src/internal/memberMessagingProtocol.js +++ b/agent-teams-controller/src/internal/memberMessagingProtocol.js @@ -1,14 +1,17 @@ function normalizeRuntimeProvider(value) { const normalized = String(value || '').trim().toLowerCase(); - return normalized === 'opencode' ? 'opencode' : 'native'; + if (normalized === 'opencode') return 'opencode'; + if (normalized === 'codex') return 'codex'; + return 'native'; } function createMemberMessagingProtocol(runtimeProvider) { const provider = normalizeRuntimeProvider(runtimeProvider); - if (provider === 'opencode') { + if (provider === 'opencode' || provider === 'codex') { + const runtimeLabel = provider === 'opencode' ? 'OpenCode' : 'Codex Native'; return { - runtimeProvider: 'opencode', + runtimeProvider: provider, sendToolName: 'agent-teams_message_send', sendToolAliases: [ 'agent-teams_message_send', @@ -25,6 +28,10 @@ function createMemberMessagingProtocol(runtimeProvider) { buildCrossTeamMessageExample({ teamName, toTeam, fromName, text, summary }) { return `agent-teams_cross_team_send { teamName: "${teamName}", toTeam: "${toTeam}", fromMember: "${fromName}", text: "${text}", summary: "${summary}" }`; }, + visibleMessageRule: + `${runtimeLabel} visible messaging rule: call agent-teams_message_send for normal replies to the human user, lead, or same-team teammates. Always include teamName, to, from, text, and summary. Do not use SendMessage or runtime_deliver_message for ordinary replies.`, + taskToolHint: + `${runtimeLabel} task tool rule: call Agent Teams task tools directly; if prefixed MCP names are exposed, use mcp__agent-teams__task_get, mcp__agent-teams__task_start, mcp__agent-teams__task_add_comment, and mcp__agent-teams__task_complete.`, }; } @@ -40,6 +47,8 @@ function createMemberMessagingProtocol(runtimeProvider) { buildCrossTeamMessageExample({ teamName, toTeam, fromName, text, summary }) { return `cross_team_send { teamName: "${teamName}", toTeam: "${toTeam}", fromMember: "${fromName}", text: "${text}", summary: "${summary}" }`; }, + visibleMessageRule: '', + taskToolHint: '', }; } @@ -52,8 +61,18 @@ function isOpenCodeMember(member) { return model.startsWith('opencode/'); } +function isCodexMember(member) { + const provider = String((member && (member.providerId || member.provider)) || '') + .trim() + .toLowerCase(); + if (provider) return provider === 'codex'; + const model = String((member && member.model) || '').trim().toLowerCase(); + return model.startsWith('gpt-') || model.startsWith('openai/gpt-'); +} + module.exports = { createMemberMessagingProtocol, + isCodexMember, isOpenCodeMember, normalizeRuntimeProvider, }; diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index 82d49ab9..27213f65 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -8,6 +8,7 @@ const { withTeamBoardLock } = require('./boardLock.js'); const { wrapAgentBlock } = require('./agentBlocks.js'); const { createMemberMessagingProtocol, + isCodexMember, isOpenCodeMember, } = require('./memberMessagingProtocol.js'); @@ -72,6 +73,12 @@ function warnNonCritical(message, error) { console.warn(`${message}: ${error instanceof Error ? error.message : String(error)}`); } +function resolveMemberRuntimeProvider(member) { + if (isOpenCodeMember(member)) return 'opencode'; + if (isCodexMember(member)) return 'codex'; + return 'native'; +} + function buildAssignmentMessage(context, task, options = {}) { const messagingProtocol = options.messagingProtocol || createMemberMessagingProtocol('native'); const description = @@ -104,10 +111,12 @@ function buildAssignmentMessage(context, task, options = {}) { text: `#${task.displayId || task.id} done. <2-4 sentence summary>. Full details in task comment . Moving to next task.`, summary: `#${task.displayId || task.id} done`, }); - const openCodeVisibleMessageRule = - messagingProtocol.runtimeProvider === 'opencode' - ? '\n For normal visible replies, use agent-teams_message_send. Do not use SendMessage or runtime_deliver_message for ordinary replies.' - : ''; + const runtimeVisibleMessageRule = messagingProtocol.visibleMessageRule + ? `\n ${messagingProtocol.visibleMessageRule}` + : ''; + const runtimeTaskToolHint = messagingProtocol.taskToolHint + ? `\n ${messagingProtocol.taskToolHint}` + : ''; lines.push( ``, @@ -123,7 +132,7 @@ function buildAssignmentMessage(context, task, options = {}) { The response contains comment.id (UUID). Take its first 8 characters as the short commentId. task_complete { teamName: "${context.teamName}", taskId: "${task.id}" } 5. After task_complete, notify your lead via ${messagingProtocol.sendLeadPhrase} with a brief summary and a pointer to the full comment (use the short commentId from step 4). - Example: ${notifyLeadExample}${openCodeVisibleMessageRule}`) + Example: ${notifyLeadExample}${runtimeVisibleMessageRule}${runtimeTaskToolHint}`) ); return lines.join('\n'); @@ -189,9 +198,7 @@ function maybeNotifyAssignedOwner(context, task, options = {}) { const ownerMember = (resolved.members || []).find( (member) => isSameMember(member && member.name, owner) ); - const messagingProtocol = createMemberMessagingProtocol( - isOpenCodeMember(ownerMember) ? 'opencode' : 'native' - ); + const messagingProtocol = createMemberMessagingProtocol(resolveMemberRuntimeProvider(ownerMember)); const summary = options.summary || `New task #${task.displayId || task.id} assigned`; try { @@ -696,10 +703,12 @@ function buildMemberTaskProtocol(teamName, messagingProtocol = createMemberMessa text: '#abcd1234 done. Found 3 competitors: two lack kanban, one went closed-source in Jan. Full details in task comment e5f6a7b8. Moving to #efgh5678 next.', summary: '#abcd1234 done', }); - const openCodeVisibleMessageRule = - messagingProtocol.runtimeProvider === 'opencode' - ? '\n - For normal visible replies, use agent-teams_message_send. Always include teamName, to, from, text, and summary. Always set from to your teammate name. Do not use SendMessage or runtime_deliver_message for ordinary replies.' - : ''; + const runtimeVisibleMessageRule = messagingProtocol.visibleMessageRule + ? `\n - ${messagingProtocol.visibleMessageRule}` + : ''; + const runtimeTaskToolHint = messagingProtocol.taskToolHint + ? `\n - ${messagingProtocol.taskToolHint}` + : ''; return wrapAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task: 0. IMPORTANT ID RULE: - If a board/task snapshot shows a canonical taskId, prefer using that exact value in MCP tool calls. @@ -722,7 +731,7 @@ function buildMemberTaskProtocol(teamName, messagingProtocol = createMemberMessa - After that, run task_complete again before your reply. - Never do comment-driven implementation/fix work while the task is still shown as pending, review, completed, or approved. - After task_complete, send a notification to your team lead via ${messagingProtocol.sendLeadPhrase}. Use the comment.id you saved earlier (first 8 characters). Your message must include: (a) which task is done, (b) a brief summary of the outcome (2-4 sentences), (c) a pointer to the full comment so the lead can fetch it, (d) what you will do next. Do NOT duplicate the entire results. - Example: ${notifyLeadExample}${openCodeVisibleMessageRule} + Example: ${notifyLeadExample}${runtimeVisibleMessageRule}${runtimeTaskToolHint} - After task_complete, call review_request ONLY when review is explicitly expected for THIS task and a concrete reviewer is already known. Example: { teamName: "${teamName}", taskId: "", from: "", reviewer: "" } @@ -891,7 +900,7 @@ async function memberBriefing(context, memberName, options = {}) { const leadName = runtimeHelpers.inferLeadName(context.paths); const effectiveMember = member; const messagingProtocol = createMemberMessagingProtocol( - options.runtimeProvider || (isOpenCodeMember(effectiveMember) ? 'opencode' : 'native') + options.runtimeProvider || resolveMemberRuntimeProvider(effectiveMember) ); const role = @@ -937,12 +946,13 @@ async function memberBriefing(context, memberName, options = {}) { `CRITICAL: If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on that same task, FIRST leave a short task comment saying what you are about to do, THEN move it to in_progress with task_start, THEN do the work, and when finished leave a short result comment and move it to done with task_complete. Never skip this comment -> reopen -> work -> comment -> done cycle.`, `CRITICAL: When you finish a task, your results (findings, research report, analysis, code changes summary, or any deliverable) MUST be posted as a task comment via task_add_comment BEFORE calling task_complete. Save the comment.id from the response — you will need it in the next step. The task comment is the primary delivery channel — the user reads results on the task board. A direct message to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only send a direct message without a task comment, the user will never see your work.`, `After task_complete, notify your team lead via ${messagingProtocol.sendLeadPhrase}. Use the comment.id you saved (first 8 characters). Include: task ref, brief summary (2-4 sentences), pointer to full comment, and next step. Example: ${completionNotifyExample}`, - ...(messagingProtocol.runtimeProvider === 'opencode' + ...(messagingProtocol.runtimeProvider !== 'native' ? [ - 'OpenCode visible messaging rule: call agent-teams_message_send for normal replies to the human user, lead, or same-team teammates. Always include teamName, to, from, text, and summary. Do not use SendMessage or runtime_deliver_message for ordinary replies.', - 'OpenCode bootstrap silence rule: if this briefing was requested because the desktop app attached or reconnected you, do not send readiness, understood, idle, or no-task acknowledgements to the user, lead, or teammates.', + messagingProtocol.visibleMessageRule, + `${messagingProtocol.runtimeProvider === 'opencode' ? 'OpenCode' : 'Codex Native'} bootstrap silence rule: if this briefing was requested because the desktop app attached or reconnected you, do not send readiness, understood, idle, or no-task acknowledgements to the user, lead, or teammates.`, 'This briefing already includes your current Task briefing. If it shows no actionable tasks, stop and wait silently. Do not call task_briefing again in the same bootstrap turn just to check for work.', 'Use agent-teams_message_send only for actual app-delivered messages, actionable task coordination, blockers, or task results.', + messagingProtocol.taskToolHint, 'For cross-team replies or messages to another team, call agent-teams_cross_team_send with toTeam/fromMember. Do not put "cross_team_send" or a remote team name into message_send.to.', ] : []), diff --git a/agent-teams-controller/src/internal/workSync.js b/agent-teams-controller/src/internal/workSync.js index 41f78c08..9fe70b3f 100644 --- a/agent-teams-controller/src/internal/workSync.js +++ b/agent-teams-controller/src/internal/workSync.js @@ -98,13 +98,17 @@ async function requestJson(baseUrl, pathname, options = {}) { } } +function isRetryableControlApiStatus(statusCode) { + return statusCode === 404 || statusCode === 408 || statusCode === 429 || statusCode >= 500; +} + async function requestJsonWithFallback(baseUrls, pathname, options = {}) { let lastError = null; for (const baseUrl of baseUrls) { try { return await requestJson(baseUrl, pathname, options); } catch (error) { - if (error && error.controlApiStatus) { + if (error && error.controlApiStatus && !isRetryableControlApiStatus(error.controlApiStatus)) { throw error; } lastError = error; diff --git a/agent-teams-controller/test/atomicFile.test.js b/agent-teams-controller/test/atomicFile.test.js index 9395d97c..1ab3687e 100644 --- a/agent-teams-controller/test/atomicFile.test.js +++ b/agent-teams-controller/test/atomicFile.test.js @@ -19,9 +19,23 @@ function withMockedRenameSync(mockRenameSync, callback) { } describe('atomic file writes', () => { + const tempDirs = []; + + function makeTempDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-atomic-')); + tempDirs.push(dir); + return dir; + } + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + ['EPERM', 'EACCES', 'EBUSY'].forEach((code) => { it(`retries transient ${code} rename failures before publishing JSON`, () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-atomic-')); + const dir = makeTempDir(); const filePath = path.join(dir, 'state.json'); let attempts = 0; @@ -47,7 +61,7 @@ describe('atomic file writes', () => { }); it('does not retry ENOENT rename failures and removes the temp file', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-atomic-')); + const dir = makeTempDir(); const filePath = path.join(dir, 'state.json'); let attempts = 0; @@ -69,7 +83,7 @@ describe('atomic file writes', () => { }); it('removes the temp file after retryable rename failures are exhausted', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-atomic-')); + const dir = makeTempDir(); const filePath = path.join(dir, 'state.json'); let attempts = 0; diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index bf736cb2..ed42338a 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -6,8 +6,17 @@ const path = require('path'); const { createController } = require('../src/index.js'); describe('agent-teams-controller API', () => { + const tempDirs = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + function makeClaudeDir() { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-controller-')); + tempDirs.push(dir); fs.mkdirSync(path.join(dir, 'teams', 'my-team'), { recursive: true }); fs.mkdirSync(path.join(dir, 'tasks', 'my-team'), { recursive: true }); fs.writeFileSync( @@ -192,6 +201,28 @@ describe('agent-teams-controller API', () => { expect(briefing).not.toContain('notify your team lead via SendMessage'); }); + it('uses Codex-native visible-message and prefixed task tool wording for Codex member briefing', async () => { + const claudeDir = makeClaudeDir(); + const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + config.members = [ + { name: 'alice', role: 'team-lead' }, + { name: 'bob', role: 'developer', providerId: 'codex', model: 'gpt-5.4-mini' }, + ]; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + + const controller = createController({ teamName: 'my-team', claudeDir }); + const briefing = await controller.tasks.memberBriefing('bob'); + + expect(briefing).toContain( + 'After task_complete, notify your team lead via MCP tool agent-teams_message_send.' + ); + expect(briefing).toContain('Codex Native visible messaging rule'); + expect(briefing).toContain('Codex Native task tool rule'); + expect(briefing).toContain('mcp__agent-teams__task_get'); + expect(briefing).not.toContain('notify your team lead via SendMessage'); + }); + it('rejects OpenCode idle acknowledgements without explicit delivery context', () => { const claudeDir = makeClaudeDir(); const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json'); @@ -295,7 +326,7 @@ describe('agent-teams-controller API', () => { expect(rows[0].summary).toBe('ready'); }); - it('does not infer OpenCode briefing from a generic provider-scoped model alone', async () => { + it('infers Codex-native briefing from a generic provider-scoped GPT model', async () => { const claudeDir = makeClaudeDir(); const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json'); const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); @@ -308,8 +339,10 @@ describe('agent-teams-controller API', () => { const controller = createController({ teamName: 'my-team', claudeDir }); const briefing = await controller.tasks.memberBriefing('bob'); - expect(briefing).toContain('After task_complete, notify your team lead via SendMessage.'); - expect(briefing).not.toContain('agent-teams_message_send'); + expect(briefing).toContain( + 'After task_complete, notify your team lead via MCP tool agent-teams_message_send.' + ); + expect(briefing).toContain('Codex Native visible messaging rule'); }); it('keeps explicit native provider metadata stronger than OpenCode-looking model labels', async () => { @@ -318,7 +351,12 @@ describe('agent-teams-controller API', () => { const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); config.members = [ { name: 'alice', role: 'team-lead' }, - { name: 'bob', role: 'developer', providerId: 'codex', model: 'opencode/minimax-m2.5-free' }, + { + name: 'bob', + role: 'developer', + providerId: 'anthropic', + model: 'opencode/minimax-m2.5-free', + }, ]; fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); @@ -974,6 +1012,34 @@ describe('agent-teams-controller API', () => { expect(controller.tasks.getTask(deletedTask.id).status).toBe('deleted'); }); + it('allows direct manual approval kanban shortcut for completed tasks outside review', () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const task = controller.tasks.createTask({ + subject: 'Completed manual shortcut', + owner: 'bob', + }); + + controller.tasks.completeTask(task.id, 'bob'); + + expect(() => controller.review.approveReview(task.id, { from: 'alice' })).toThrow( + 'must be in review before approval' + ); + expect(() => controller.kanban.setKanbanColumn(task.id, 'approved')).toThrow( + 'must already be approved' + ); + + const state = controller.kanban.setKanbanColumn(task.id, 'approved', { + transition: 'manual_approve', + }); + expect(state.tasks[task.id].column).toBe('approved'); + const approvedTask = controller.tasks.getTask(task.id); + expect(approvedTask.reviewState).toBe('approved'); + expect( + (approvedTask.historyEvents || []).filter((event) => event.type === 'review_approved') + ).toHaveLength(0); + }); + it('rejects review_start outside active review and keeps owner routing intact', async () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); @@ -1598,6 +1664,30 @@ describe('agent-teams-controller API', () => { ]); }); + it('uses Codex-native MCP wording in owner assignment notifications for Codex members', () => { + const claudeDir = makeClaudeDir(); + const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + config.members = [ + { name: 'alice', role: 'team-lead' }, + { name: 'bob', role: 'developer', providerId: 'codex', model: 'gpt-5.4-mini' }, + ]; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + const controller = createController({ teamName: 'my-team', claudeDir }); + + controller.tasks.createTask({ + subject: 'Implement Codex handoff', + owner: 'bob', + }); + + const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json'); + const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); + expect(rows[0].text).toContain('MCP tool agent-teams_message_send'); + expect(rows[0].text).toContain('Codex Native visible messaging rule'); + expect(rows[0].text).toContain('mcp__agent-teams__task_get'); + expect(rows[0].text).not.toContain('notify your lead via SendMessage'); + }); + it('does not wake owner for self-comments and keeps user clarification sticky until explicitly cleared', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); diff --git a/agent-teams-controller/test/crossTeam.test.js b/agent-teams-controller/test/crossTeam.test.js index 9499f71e..60251f1c 100644 --- a/agent-teams-controller/test/crossTeam.test.js +++ b/agent-teams-controller/test/crossTeam.test.js @@ -6,8 +6,11 @@ const { createController } = require('../src/index.js'); const { CROSS_TEAM_SOURCE, CROSS_TEAM_TAG_NAME } = require('../src/internal/crossTeamProtocol.js'); describe('crossTeam module', () => { + const tempDirs = []; + function makeClaudeDir(teams = {}) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'crossteam-test-')); + tempDirs.push(dir); for (const [teamName, config] of Object.entries(teams)) { const teamDir = path.join(dir, 'teams', teamName); @@ -28,6 +31,9 @@ describe('crossTeam module', () => { // Reset cascade guard between tests const cascadeGuard = require('../src/internal/cascadeGuard.js'); cascadeGuard.reset(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } }); describe('sendCrossTeamMessage', () => { diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 7d5c317b..acef573b 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -194,6 +194,22 @@ EOF Write changelog entries from the **user's perspective**, not the developer's. +Release notes must stay short, concrete, and user-facing. Do not include internal +maintenance details unless they directly change what users can do or clearly fix +a user-visible problem. + +Avoid entries about: + +- CI/lint/test gates, smoke tests, or validation infrastructure. +- README/docs cleanup, roadmap checkbox changes, or release-process polish. +- Runtime artifact internals, bundled runtime version numbers, stable aliases, + compatibility aliases, or updater plumbing. +- Refactors, dependency bumps, or workflow changes without a user-visible effect. + +If a change only made future releases, tests, packaging, or developer validation +more reliable, keep it out of the public notes or fold it into one concise +user-facing line only when it explains a real fix. + **Good:** - "Add team member activity timeline with live status tracking" @@ -205,6 +221,11 @@ Write changelog entries from the **user's perspective**, not the developer's. - "Refactor ChunkBuilder to use new pipeline" - "Update dependencies" - "Fix bug in useEffect cleanup" +- "Fix CI lint gate" +- "Stabilize provider smoke tests" +- "Update README install guidance" +- "Bundled runtime remains vX.Y.Z" +- "Compatibility aliases are still included" Group entries by type: `What's New` > `Improvements` > `Bug Fixes` > `Breaking Changes` (if any). diff --git a/landing/README.md b/landing/README.md index 0dcfcd38..fb679a08 100644 --- a/landing/README.md +++ b/landing/README.md @@ -18,3 +18,5 @@ pnpm preview - Static-first (SSG) by design. - Locale auto-detection: cookie -> browser settings -> fallback `en`. - Theme auto-detection: localStorage -> system preference -> fallback `light`. +- Hero video uses the Mux Player embed. Set `NUXT_PUBLIC_MUX_PLAYBACK_ID` to override the default playback id without changing the code. +- Hero background can use a separate Mux asset via `NUXT_PUBLIC_MUX_BACKGROUND_PLAYBACK_ID`; otherwise it reuses `NUXT_PUBLIC_MUX_PLAYBACK_ID`. diff --git a/landing/assets/styles/cyberpunk-hero.scss b/landing/assets/styles/cyberpunk-hero.scss index 5929e57f..19716fff 100644 --- a/landing/assets/styles/cyberpunk-hero.scss +++ b/landing/assets/styles/cyberpunk-hero.scss @@ -34,8 +34,13 @@ --cyber-monterey-after-bg: linear-gradient(180deg, rgba(2, 5, 13, 0.92) 0%, rgba(2, 5, 13, 0.62) 15%, rgba(2, 5, 13, 0.08) 44%, rgba(2, 5, 13, 0.68) 100%), radial-gradient(circle at 64% 42%, transparent 0 26%, rgba(2, 5, 13, 0.24) 70%, rgba(2, 5, 13, 0.54) 100%); - --cyber-monterey-canvas-opacity: 0.42; - --cyber-monterey-canvas-filter: blur(4px) saturate(0.78) brightness(0.62) contrast(1.08); + --cyber-monterey-video-opacity: 1; + --cyber-monterey-video-filter: blur(0.8px) saturate(1.12) contrast(1.04); + --cyber-monterey-video-blend: normal; + --cyber-monterey-video-position: 74% 50%; + --cyber-monterey-video-scale: 1.16; + --cyber-monterey-canvas-opacity: 0.36; + --cyber-monterey-canvas-filter: blur(0.5px) saturate(0.92) brightness(0.78) contrast(1.08); --cyber-monterey-canvas-blend: normal; --cyber-background-bg: radial-gradient(circle at 72% 28%, rgba(0, 234, 255, 0.1), transparent 30%), @@ -116,113 +121,6 @@ --cyber-frame-cut: 18px; } -.v-theme--light .cyber-hero { - --cyber-bg-0: #f8fcff; - --cyber-bg-1: #eaf7fb; - --cyber-panel-weak: rgba(255, 255, 255, 0.68); - --cyber-panel: rgba(255, 255, 255, 0.78); - --cyber-panel-strong: rgba(255, 255, 255, 0.92); - --cyber-cyan: #008fb3; - --cyber-blue: #2563eb; - --cyber-magenta: #b832d8; - --cyber-violet: #7c3aed; - --cyber-text: #132238; - --cyber-muted: #596b83; - --cyber-border-cyan: rgba(8, 145, 178, 0.34); - --cyber-border-magenta: rgba(184, 50, 216, 0.28); - --cyber-panel-bg: - linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(237, 249, 252, 0.68)); - --cyber-panel-shadow: - 0 0 0 1px rgba(8, 145, 178, 0.1) inset, - 0 18px 48px rgba(8, 145, 178, 0.12), - 0 0 22px rgba(184, 50, 216, 0.07); - --cyber-hero-bg: - radial-gradient(circle at 76% 30%, rgba(0, 178, 214, 0.14), transparent 32%), - radial-gradient(circle at 86% 70%, rgba(184, 50, 216, 0.13), transparent 36%), - linear-gradient(180deg, #fbfdff 0%, #edf8fb 56%, #f8fcff 100%); - --cyber-monterey-bg: - radial-gradient(circle at 78% 24%, rgba(113, 185, 255, 0.28), transparent 31%), - radial-gradient(circle at 22% 72%, rgba(221, 170, 255, 0.24), transparent 38%), - radial-gradient(circle at 8% 32%, rgba(101, 218, 255, 0.18), transparent 34%), - linear-gradient(180deg, #f8fcff 0%, #eaf7fb 48%, #fbf7ff 100%); - --cyber-monterey-before-bg: - radial-gradient(circle at 18% 34%, rgba(255, 255, 255, 0.46), rgba(255, 255, 255, 0.14) 34%, transparent 62%), - linear-gradient(90deg, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0.08) 42%, rgba(255, 255, 255, 0.03) 64%, rgba(237, 247, 252, 0.2) 100%); - --cyber-monterey-after-bg: - linear-gradient(180deg, rgba(248, 252, 255, 0.52) 0%, rgba(248, 252, 255, 0.22) 18%, rgba(248, 252, 255, 0.04) 48%, rgba(248, 252, 255, 0.58) 100%), - radial-gradient(circle at 64% 42%, transparent 0 26%, rgba(255, 255, 255, 0.16) 70%, rgba(235, 247, 252, 0.42) 100%); - --cyber-monterey-canvas-opacity: 1; - --cyber-monterey-canvas-filter: blur(1px) saturate(1.34) brightness(1.12) contrast(1.16); - --cyber-monterey-canvas-blend: multiply; - --cyber-background-bg: - radial-gradient(circle at 72% 28%, rgba(0, 178, 214, 0.12), transparent 31%), - radial-gradient(circle at 88% 62%, rgba(184, 50, 216, 0.1), transparent 33%), - linear-gradient(90deg, transparent 0 64px, rgba(8, 145, 178, 0.06) 65px 66px, transparent 67px 160px), - linear-gradient(180deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0.08) 38%, rgba(237, 247, 252, 0.64) 100%); - --cyber-background-opacity: 0.5; - --cyber-background-before-bg: - linear-gradient(90deg, transparent 0 8%, rgba(8, 145, 178, 0.16) 8.1% 8.22%, transparent 8.34% 18%, rgba(184, 50, 216, 0.12) 18.1% 18.22%, transparent 18.34% 31%, rgba(8, 145, 178, 0.13) 31.1% 31.24%, transparent 31.36% 44%, rgba(37, 99, 235, 0.12) 44.1% 44.2%, transparent 44.34% 62%, rgba(184, 50, 216, 0.1) 62.1% 62.22%, transparent 62.34% 78%, rgba(8, 145, 178, 0.12) 78.1% 78.22%, transparent 78.34%), - repeating-linear-gradient(90deg, transparent 0 78px, rgba(8, 145, 178, 0.06) 80px 82px, transparent 84px 116px), - linear-gradient(to top, rgba(8, 145, 178, 0.12) 0%, rgba(8, 145, 178, 0.07) 13%, transparent 31%), - linear-gradient(to top, rgba(184, 50, 216, 0.1) 0%, rgba(184, 50, 216, 0.05) 16%, transparent 38%), - linear-gradient(to top, rgba(37, 99, 235, 0.09) 0%, rgba(37, 99, 235, 0.04) 20%, transparent 48%); - --cyber-background-before-opacity: 0.58; - --cyber-background-before-blend: multiply; - --cyber-background-after-bg: - repeating-linear-gradient(90deg, transparent 0 34px, rgba(8, 145, 178, 0.1) 35px 36px, transparent 37px 110px), - repeating-linear-gradient(180deg, transparent 0 28px, rgba(184, 50, 216, 0.08) 29px 30px, transparent 31px 78px), - linear-gradient(90deg, transparent, rgba(8, 145, 178, 0.07), transparent); - --cyber-background-after-opacity: 0.18; - --cyber-background-after-blend: multiply; - --cyber-wash-bg: - radial-gradient(circle at 18% 44%, rgba(255, 255, 255, 0.28), rgba(255, 255, 255, 0.12) 36%, transparent 60%), - linear-gradient(90deg, rgba(255, 255, 255, 0.18) 0%, rgba(237, 248, 252, 0.1) 36%, rgba(255, 255, 255, 0.03) 68%, rgba(251, 247, 255, 0.16) 100%), - linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(248, 252, 255, 0.04) 58%, rgba(248, 252, 255, 0.72)); - --cyber-gridlines-bg: - linear-gradient(rgba(8, 145, 178, 0.055) 1px, transparent 1px), - linear-gradient(90deg, rgba(8, 145, 178, 0.045) 1px, transparent 1px); - --cyber-gridlines-opacity: 0.2; - --cyber-scanlines-bg: repeating-linear-gradient( - to bottom, - rgba(8, 35, 50, 0.035) 0, - rgba(8, 35, 50, 0.035) 1px, - transparent 1px, - transparent 4px - ); - --cyber-scanlines-opacity: 0.08; - --cyber-copy-aura: radial-gradient(circle at 28% 38%, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.38) 58%, transparent 78%); - --cyber-title-color: rgba(19, 34, 56, 0.98); - --cyber-description-color: rgba(50, 65, 88, 0.82); - --cyber-action-primary-color: #061722; - --cyber-action-secondary-bg: rgba(255, 255, 255, 0.62); - --cyber-action-secondary-hover-bg: rgba(8, 145, 178, 0.08); - --cyber-release-color: rgba(50, 65, 88, 0.68); - --cyber-scene-floor-bg: - radial-gradient(ellipse at 58% 84%, rgba(184, 50, 216, 0.16), transparent 18%), - radial-gradient(ellipse at 56% 84%, rgba(8, 145, 178, 0.14), transparent 32%), - repeating-radial-gradient(ellipse at 58% 84%, rgba(8, 145, 178, 0.08) 0 1px, transparent 1px 20px); - --cyber-scene-floor-opacity: 0.38; - --cyber-scene-foreground-bg: - linear-gradient(90deg, transparent 0 4%, rgba(8, 145, 178, 0.07) 4.1%, transparent 4.4%), - linear-gradient(180deg, transparent 0 88%, rgba(184, 50, 216, 0.06)); - --cyber-scene-foreground-opacity: 0.48; - --cyber-video-frame-bg: rgba(255, 255, 255, 0.7); - --cyber-video-content-bg: rgba(8, 20, 34, 0.9); - --cyber-card-text: rgba(19, 34, 56, 0.88); - --cyber-card-muted: rgba(67, 82, 105, 0.76); - --cyber-card-subtle: rgba(67, 82, 105, 0.64); - --cyber-card-inset: rgba(255, 255, 255, 0.62); - --cyber-feature-shell-bg: transparent; - --cyber-feature-rail-bg: transparent; - --cyber-feature-rail-shadow: - 0 0 0 1px rgba(8, 145, 178, 0.12) inset, - 0 -1px 0 rgba(8, 145, 178, 0.18), - 0 1px 0 rgba(8, 145, 178, 0.16); - --cyber-feature-divider: rgba(8, 145, 178, 0.18); - --cyber-feature-title: rgba(19, 34, 56, 0.94); - --cyber-feature-text: rgba(67, 82, 105, 0.72); -} - .cyber-panel { position: relative; border: 1px solid var(--cyber-border-cyan); @@ -294,6 +192,65 @@ background: var(--cyber-monterey-after-bg); } +.cyber-hero__background, +.cyber-hero__wash, +.cyber-hero__monterey::before, +.cyber-hero__monterey::after { + display: none; +} + +.cyber-hero__monterey-video { + position: absolute; + inset: 0; + z-index: 0; + width: 100%; + height: 100%; + min-width: 100%; + min-height: 100%; + overflow: hidden; + opacity: var(--cyber-monterey-video-opacity); + filter: var(--cyber-monterey-video-filter); + mix-blend-mode: var(--cyber-monterey-video-blend); + background: + var(--cyber-monterey-video-poster) var(--cyber-monterey-video-position) / cover no-repeat, + var(--cyber-monterey-bg); + transform: scale(var(--cyber-monterey-video-scale)); + will-change: opacity, transform; +} + +.cyber-hero__monterey-video::after { + display: none; +} + +.cyber-hero__monterey-video-player { + position: absolute; + inset: 0; + z-index: 1; + display: block; + width: 100%; + height: 100%; + min-width: 100%; + min-height: 100%; + opacity: 0; + background: transparent; + object-fit: cover; + object-position: var(--cyber-monterey-video-position); + --media-object-fit: cover; + --media-object-position: var(--cyber-monterey-video-position); + transition: opacity 0.45s ease; +} + +.cyber-hero__monterey-video-player::part(video) { + width: 100%; + height: 100%; + object-fit: cover; + object-position: var(--cyber-monterey-video-position); +} + +.cyber-hero__monterey-video-player--ready { + opacity: 1; +} + .cyber-hero__monterey-canvas { position: absolute; inset: 0; @@ -490,60 +447,251 @@ .cyber-hero__description { max-width: 560px; - margin: 0 0 30px; + margin: 0 0 20px; color: var(--cyber-description-color); font-size: clamp(1rem, 1.08vw, 1.22rem); line-height: 1.7; } +.cyber-hero__providers { + width: min(680px, 100%); + margin: 0 0 20px; +} + +.cyber-hero__provider-list { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 14px 26px; +} + +.cyber-hero__provider { + --provider-accent: var(--cyber-cyan); + + display: inline-flex; + align-items: center; + min-width: 0; + gap: 9px; + color: var(--cyber-text); + text-shadow: + 0 0 18px color-mix(in srgb, var(--provider-accent) 26%, transparent), + 0 0 1px rgba(255, 255, 255, 0.3); +} + +.cyber-hero__provider--cyan { + --provider-accent: var(--cyber-cyan); +} + +.cyber-hero__provider--amber { + --provider-accent: #ffcf5a; +} + +.cyber-hero__provider--magenta { + --provider-accent: var(--cyber-magenta); +} + +.cyber-hero__provider-icon { + display: grid; + flex: 0 0 auto; + width: 30px; + height: 30px; + place-items: center; + color: var(--provider-accent); + filter: drop-shadow(0 0 10px color-mix(in srgb, var(--provider-accent) 44%, transparent)); +} + +.cyber-hero__provider-icon svg { + width: 24px; + height: 24px; +} + +.cyber-hero__provider-name { + overflow-wrap: anywhere; + color: var(--cyber-text); + font-size: 0.98rem; + font-weight: 900; + line-height: 1.1; +} + .cyber-hero__actions { display: flex; flex-wrap: wrap; - gap: 12px; + gap: 14px; margin-bottom: 18px; } -.cyber-hero__action.v-btn { - min-height: 52px !important; - min-width: 148px !important; - border-radius: var(--cyber-radius-sm) !important; - padding-inline: 18px !important; - font-weight: 800 !important; - font-size: 0.9rem !important; - letter-spacing: 0.01em !important; +.cyber-action-button.v-btn { + --action-accent-a: var(--cyber-cyan); + --action-accent-b: var(--cyber-magenta); + --action-bg: + linear-gradient(135deg, rgba(0, 210, 255, 0.74) 0%, rgba(43, 103, 255, 0.56) 48%, rgba(207, 34, 215, 0.76) 100%), + linear-gradient(180deg, rgba(7, 18, 42, 0.9), rgba(4, 10, 25, 0.92)); + --action-border: rgba(0, 234, 255, 0.78); + --action-text: #f2fbff; + --action-subtext: rgba(231, 240, 255, 0.72); + --action-clip: polygon(18px 0, calc(100% - 12px) 0, 100% 12px, 100% calc(100% - 18px), calc(100% - 18px) 100%, 0 100%, 0 18px); + + position: relative; + min-height: 76px !important; + min-width: 312px !important; + overflow: hidden !important; + border: 0 !important; + border-radius: 0 !important; + padding: 0 26px !important; + color: var(--action-text) !important; + background: var(--action-bg) !important; + clip-path: var(--action-clip); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.1) inset, + 0 0 22px color-mix(in srgb, var(--action-accent-a) 24%, transparent), + 0 0 34px color-mix(in srgb, var(--action-accent-b) 20%, transparent) !important; + font-weight: 900 !important; + letter-spacing: 0 !important; text-transform: uppercase !important; transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease, - background 0.18s ease !important; + filter 0.18s ease !important; } -.cyber-hero__action.v-btn:hover { - transform: translateY(-1px); +.cyber-action-button.v-btn .v-btn__content { + position: static; + display: flex; + align-items: center; + justify-content: flex-start; + width: 100%; + gap: 16px; } -.cyber-hero__action--primary.v-btn { - color: var(--cyber-action-primary-color) !important; - background: linear-gradient(135deg, var(--cyber-cyan), var(--cyber-magenta)) !important; +.cyber-action-button.v-btn .v-btn__overlay, +.cyber-action-button.v-btn .v-btn__underlay { + display: none !important; +} + +.cyber-action-button.v-btn:hover { + filter: brightness(1.08) saturate(1.08); + transform: translateY(-2px); +} + +.cyber-action-button__glow { + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.16), transparent 28%), + radial-gradient(ellipse at 16% 48%, color-mix(in srgb, var(--action-accent-a) 22%, transparent), transparent 38%), + linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent 52%); + opacity: 0.62; +} + +.cyber-action-button--primary .cyber-action-button__glow::after { + position: absolute; + inset: -42% -58%; + content: ""; + background: + linear-gradient( + 108deg, + transparent 0 42%, + rgba(255, 255, 255, 0.18) 47%, + rgba(255, 255, 255, 0.58) 50%, + rgba(0, 234, 255, 0.24) 53%, + transparent 59% 100% + ); + mix-blend-mode: screen; + opacity: 0; + transform: translate3d(-64%, 0, 0) skewX(-18deg); + animation: cyberActionButtonShine 4.8s cubic-bezier(0.25, 0.1, 0.22, 1) infinite; +} + +.cyber-action-button__frame { + position: absolute; + inset: 0; + z-index: 3; + width: 100%; + height: 100%; + overflow: visible; + color: var(--action-border); + pointer-events: none; + filter: + drop-shadow(0 0 8px color-mix(in srgb, var(--action-accent-a) 22%, transparent)) + drop-shadow(0 0 12px color-mix(in srgb, var(--action-accent-b) 12%, transparent)); +} + +.cyber-action-button__frame path { + fill: none; + stroke: currentColor; + stroke-linecap: square; + stroke-linejoin: miter; + stroke-width: 1.35; + shape-rendering: geometricPrecision; +} + +.cyber-action-button__icon { + position: relative; + z-index: 4; + display: grid; + flex: 0 0 auto; + width: 34px; + height: 34px; + place-items: center; + color: var(--action-accent-a); + filter: + drop-shadow(0 0 10px color-mix(in srgb, var(--action-accent-a) 54%, transparent)) + drop-shadow(0 0 18px color-mix(in srgb, var(--action-accent-b) 30%, transparent)); +} + +.cyber-action-button--primary .cyber-action-button__icon { + color: #00d8ff; + filter: + drop-shadow(0 0 10px rgba(0, 234, 255, 0.56)) + drop-shadow(0 0 16px rgba(35, 91, 255, 0.22)); +} + +.cyber-action-button__copy { + position: relative; + z-index: 4; + display: grid; + min-width: 0; + gap: 6px; + text-align: left; +} + +.cyber-action-button__label { + color: var(--action-text); + font-size: 1rem; + line-height: 1; +} + +.cyber-action-button__subtitle { + color: var(--action-subtext); + font-size: 0.66rem; + font-weight: 700; + line-height: 1.1; + text-transform: none; + opacity: 0.92; +} + +.cyber-action-button--primary.v-btn { + --action-text: #f8fbff; + --action-subtext: rgba(237, 247, 255, 0.74); +} + +.cyber-action-button--secondary.v-btn { + --action-accent-a: #91b6ff; + --action-accent-b: var(--cyber-cyan); + --action-bg: + linear-gradient(135deg, rgba(10, 18, 38, 0.96), rgba(7, 14, 31, 0.78)), + rgba(1, 7, 18, 0.84); + --action-border: rgba(123, 160, 231, 0.64); + --action-text: #f3f7ff; + --action-subtext: rgba(182, 198, 226, 0.76); + box-shadow: - 0 0 0 1px rgba(255, 255, 255, 0.16) inset, - 0 0 24px rgba(0, 234, 255, 0.34), - 0 0 34px rgba(255, 43, 255, 0.22) !important; -} - -.cyber-hero__action--watch.v-btn, -.cyber-hero__action--docs.v-btn { - color: var(--cyber-text) !important; - border-color: rgba(0, 234, 255, 0.46) !important; - background: var(--cyber-action-secondary-bg) !important; -} - -.cyber-hero__action--watch.v-btn:hover, -.cyber-hero__action--docs.v-btn:hover { - color: var(--cyber-cyan) !important; - border-color: rgba(0, 234, 255, 0.74) !important; - background: var(--cyber-action-secondary-hover-bg) !important; + 0 0 0 1px rgba(255, 255, 255, 0.05) inset, + 0 0 24px rgba(80, 130, 255, 0.16), + 0 0 34px rgba(0, 234, 255, 0.1) !important; } .cyber-hero__terminal-note { @@ -1193,31 +1341,67 @@ .cyber-feature-rail-shell { position: relative; z-index: 7; - margin: 28px auto 0; - width: min(1540px, 96%); + margin: clamp(42px, 5vw, 72px) auto 0; + width: min(1580px, 96%); } .cyber-feature-rail-shell::before { content: ""; position: absolute; z-index: -1; - inset: -118px -3vw -96px; + inset: -82px -4vw -76px; pointer-events: none; - background: var(--cyber-feature-shell-bg); - opacity: 0.86; - mask-image: radial-gradient(ellipse at 50% 54%, black 0 48%, rgba(0, 0, 0, 0.5) 62%, transparent 78%); + background: + radial-gradient(ellipse at 50% 42%, rgba(0, 234, 255, 0.12), transparent 48%), + radial-gradient(ellipse at 88% 54%, rgba(255, 43, 255, 0.12), transparent 42%); + opacity: 0.72; + mask-image: radial-gradient(ellipse at 50% 52%, black 0 48%, rgba(0, 0, 0, 0.5) 62%, transparent 78%); } .cyber-feature-rail { + --feature-line-top: 118px; + position: relative; z-index: 1; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; width: 100%; - padding: 17px 18px; - background: var(--cyber-feature-rail-bg); - box-shadow: var(--cyber-feature-rail-shadow); + min-height: 300px; + padding: 0 22px; + background: transparent; + box-shadow: none; +} + +.cyber-feature-rail::before { + position: absolute; + z-index: 0; + top: var(--feature-line-top); + left: 0; + right: 0; + height: 3px; + content: ""; + background: + linear-gradient(90deg, transparent 0 2.5%, var(--cyber-cyan) 6%, rgba(0, 234, 255, 0.92) 78%, rgba(255, 43, 255, 0.95) 92%, transparent 98%), + linear-gradient(90deg, transparent 0 3%, rgba(255, 255, 255, 0.34) 6%, transparent 13%, transparent 86%, rgba(255, 255, 255, 0.24) 92%, transparent 98%); + box-shadow: + 0 0 18px rgba(0, 234, 255, 0.42), + 0 0 30px rgba(255, 43, 255, 0.18); +} + +.cyber-feature-rail::after { + position: absolute; + z-index: 0; + top: calc(var(--feature-line-top) - 7px); + left: 0; + width: 86px; + height: 17px; + content: ""; + background: + repeating-linear-gradient(115deg, var(--cyber-cyan) 0 4px, transparent 4px 10px), + linear-gradient(90deg, transparent, rgba(0, 234, 255, 0.6)); + clip-path: polygon(0 42%, 44% 42%, 54% 0, 100% 0, 100% 100%, 54% 100%, 44% 58%, 0 58%); + filter: drop-shadow(0 0 10px rgba(0, 234, 255, 0.45)); } .cyber-feature-rail__collaboration { @@ -1388,41 +1572,127 @@ } .cyber-feature-rail__item { + --feature-accent: var(--cyber-cyan); + + position: relative; + z-index: 1; display: grid; - grid-template-columns: 46px minmax(0, 1fr); - align-items: center; - gap: 12px; + grid-template-rows: 82px 54px minmax(0, 1fr); + justify-items: center; + align-items: start; + gap: 0; min-width: 0; - padding: 0 18px; - border-right: 1px solid var(--cyber-feature-divider); + padding: 0 14px; + text-align: center; } -.cyber-feature-rail__item:last-child { - border-right: 0; +.cyber-feature-rail__item:nth-child(5) { + --feature-accent: var(--cyber-magenta); } .cyber-feature-rail__icon { + position: relative; display: grid; place-items: center; - width: 46px; - height: 46px; - color: var(--cyber-cyan); - border: 1px solid rgba(0, 234, 255, 0.44); - border-radius: var(--cyber-radius-sm); - box-shadow: 0 0 22px rgba(0, 234, 255, 0.16); + width: 80px; + height: 70px; + color: var(--feature-accent); + filter: + drop-shadow(0 0 16px color-mix(in srgb, var(--feature-accent) 48%, transparent)) + drop-shadow(0 0 28px color-mix(in srgb, var(--feature-accent) 18%, transparent)); +} + +.cyber-feature-rail__icon::before, +.cyber-feature-rail__icon::after { + position: absolute; + width: 18px; + height: 18px; + content: ""; + opacity: 0.82; +} + +.cyber-feature-rail__icon::before { + top: 0; + left: 0; + border-top: 1px solid var(--feature-accent); + border-left: 1px solid var(--feature-accent); +} + +.cyber-feature-rail__icon::after { + right: 0; + bottom: 0; + border-right: 1px solid var(--feature-accent); + border-bottom: 1px solid var(--feature-accent); +} + +.cyber-feature-rail__icon .v-icon { + font-size: 46px !important; +} + +.cyber-feature-rail__node { + position: relative; + display: grid; + width: 58px; + height: 58px; + place-items: center; + color: var(--feature-accent); + background: rgba(2, 10, 24, 0.9); + border: 2px solid var(--feature-accent); + clip-path: polygon(28% 0, 72% 0, 100% 28%, 100% 72%, 72% 100%, 28% 100%, 0 72%, 0 28%); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.08) inset, + 0 0 20px color-mix(in srgb, var(--feature-accent) 34%, transparent); + font-family: var(--at-font-mono); + font-size: 1.14rem; + font-weight: 900; + line-height: 1; +} + +.cyber-feature-rail__node::before { + position: absolute; + left: 50%; + top: -26px; + width: 1px; + height: 25px; + content: ""; + background: linear-gradient(180deg, var(--feature-accent), transparent); + box-shadow: 0 0 12px color-mix(in srgb, var(--feature-accent) 46%, transparent); +} + +.cyber-feature-rail__copy { + position: relative; + isolation: isolate; + max-width: 235px; + padding: 18px 12px 12px; +} + +.cyber-feature-rail__copy::before { + position: absolute; + z-index: -1; + inset: 2px -8px -4px; + content: ""; + background: + linear-gradient(180deg, rgba(3, 8, 22, 0.46), rgba(3, 8, 22, 0.2)), + radial-gradient(ellipse at 50% 38%, rgba(0, 234, 255, 0.08), transparent 72%); + border: 1px solid rgba(0, 234, 255, 0.08); + border-radius: 18px; + opacity: 0.92; + backdrop-filter: blur(11px) saturate(1.08); + mask-image: radial-gradient(ellipse at 50% 50%, black 0 76%, rgba(0, 0, 0, 0.62) 88%, transparent 100%); } .cyber-feature-rail__title { - margin-bottom: 3px; + margin-bottom: 11px; color: var(--cyber-feature-title); font-weight: 800; - font-size: 0.92rem; + font-size: clamp(0.98rem, 1.12vw, 1.22rem); + line-height: 1.18; } .cyber-feature-rail__text { color: var(--cyber-feature-text); - font-size: 0.8rem; - line-height: 1.45; + font-size: clamp(0.82rem, 0.88vw, 1rem); + line-height: 1.52; } @keyframes cyberRobotBob { @@ -1466,6 +1736,28 @@ } } +@keyframes cyberActionButtonShine { + 0%, + 54% { + opacity: 0; + transform: translate3d(-64%, 0, 0) skewX(-18deg); + } + + 61% { + opacity: 0.8; + } + + 76% { + opacity: 0.5; + } + + 84%, + 100% { + opacity: 0; + transform: translate3d(64%, 0, 0) skewX(-18deg); + } +} + @media (max-width: 1280px) { .cyber-hero__layout { grid-template-columns: minmax(500px, 0.9fr) minmax(0, 1.1fr); @@ -1537,36 +1829,38 @@ .cyber-feature-rail { grid-template-columns: repeat(3, minmax(0, 1fr)); + min-height: auto; + row-gap: 34px; + padding: 0; } - .cyber-feature-rail__collaboration { - left: 50%; - bottom: calc(100% - 14px); - width: 132px; + .cyber-feature-rail::before, + .cyber-feature-rail::after { + display: none; } - .cyber-feature-rail__reviewer { - --reviewer-robot-width: 78px; - - right: 56px; - gap: 10px; + .cyber-feature-rail__item { + grid-template-rows: 72px 48px minmax(0, 1fr); + padding-inline: 12px; } - .cyber-feature-rail__reviewer-card { - width: 220px; - font-size: 0.62rem; + .cyber-feature-rail__icon { + width: 72px; + height: 62px; } - .cyber-feature-rail__robot { - width: var(--reviewer-robot-width); + .cyber-feature-rail__icon .v-icon { + font-size: 38px !important; } - .cyber-feature-rail__item:nth-child(3) { - border-right: 0; + .cyber-feature-rail__node { + width: 50px; + height: 50px; + font-size: 1rem; } - .cyber-feature-rail__item:nth-child(n + 4) { - margin-top: 16px; + .cyber-feature-rail__copy { + padding-top: 12px; } } @@ -1630,14 +1924,42 @@ line-height: 1.62; } + .cyber-hero__providers { + margin-bottom: 22px; + } + + .cyber-hero__provider-list { + gap: 12px 18px; + } + + .cyber-hero__provider { + gap: 7px; + } + + .cyber-hero__provider-icon { + width: 24px; + height: 24px; + } + + .cyber-hero__provider-icon svg { + width: 20px; + height: 20px; + } + + .cyber-hero__provider-name { + font-size: 0.82rem; + } + .cyber-hero__actions { display: grid; grid-template-columns: 1fr; } - .cyber-hero__action.v-btn { + .cyber-action-button.v-btn { width: 100%; + min-height: 72px !important; min-width: 0 !important; + padding-inline: 20px !important; } .cyber-hero__terminal-note { @@ -1708,26 +2030,64 @@ .cyber-feature-rail { grid-template-columns: 1fr; - padding: 16px; - } - - .cyber-feature-rail__collaboration { - display: none; - } - - .cyber-feature-rail__reviewer { - display: none; + gap: 20px; + padding: 0 4px; } .cyber-feature-rail__item { - grid-template-columns: 42px minmax(0, 1fr); - padding: 12px 0; - border-right: 0; - border-bottom: 1px solid rgba(0, 234, 255, 0.14); + grid-template-columns: 48px 44px minmax(0, 1fr); + grid-template-rows: auto; + align-items: center; + justify-items: start; + gap: 10px; + padding: 0; + text-align: left; } - .cyber-feature-rail__item:last-child { - border-bottom: 0; + .cyber-feature-rail__icon { + width: 44px; + height: 44px; + } + + .cyber-feature-rail__icon::before, + .cyber-feature-rail__icon::after { + width: 12px; + height: 12px; + } + + .cyber-feature-rail__icon .v-icon { + font-size: 26px !important; + } + + .cyber-feature-rail__node { + width: 40px; + height: 40px; + font-size: 0.8rem; + } + + .cyber-feature-rail__node::before { + display: none; + } + + .cyber-feature-rail__copy { + max-width: none; + padding: 8px 10px 9px; + } + + .cyber-feature-rail__copy::before { + inset: 0 -6px; + border-radius: 14px; + backdrop-filter: blur(9px) saturate(1.06); + } + + .cyber-feature-rail__title { + margin-bottom: 5px; + font-size: 0.92rem; + } + + .cyber-feature-rail__text { + font-size: 0.78rem; + line-height: 1.42; } } diff --git a/landing/components/common/ThemeToggle.vue b/landing/components/common/ThemeToggle.vue index 1bc10b99..bdde56ce 100644 --- a/landing/components/common/ThemeToggle.vue +++ b/landing/components/common/ThemeToggle.vue @@ -33,7 +33,7 @@ const onToggle = () => { :icon="mdiWeatherSunny" variant="text" size="small" - aria-label="Toggle theme" + :aria-label="t('theme.light')" /> diff --git a/landing/components/hero/CyberHeroActionButton.vue b/landing/components/hero/CyberHeroActionButton.vue new file mode 100644 index 00000000..bbfeb733 --- /dev/null +++ b/landing/components/hero/CyberHeroActionButton.vue @@ -0,0 +1,49 @@ + + + diff --git a/landing/components/hero/CyberHeroFeatureStrip.vue b/landing/components/hero/CyberHeroFeatureStrip.vue index 33e75cd5..cdc354ff 100644 --- a/landing/components/hero/CyberHeroFeatureStrip.vue +++ b/landing/components/hero/CyberHeroFeatureStrip.vue @@ -6,19 +6,10 @@ import { mdiShieldCheckOutline, mdiMonitorDashboard, } from "@mdi/js"; -import { - heroCollaborationFeature, - heroFeatureRail, - heroReviewerFeatureCard, - type HeroMessage, - type HeroMessagePhase, -} from "~/data/heroScene"; +import { getLocalizedHeroFeatureRail } from "~/data/heroScene"; -const props = defineProps<{ - activeMessage?: HeroMessage | null; - phase?: HeroMessagePhase; - reducedMotion?: boolean; -}>(); +const { locale } = useI18n(); +const localizedHeroFeatureRail = computed(() => getLocalizedHeroFeatureRail(locale.value)); const icons = [ mdiRobotOutline, @@ -27,81 +18,22 @@ const icons = [ mdiShieldCheckOutline, mdiMonitorDashboard, ] as const; - -const reviewerIsSender = computed(() => - props.activeMessage?.from === "reviewer" && props.phase !== "cooldown", -); -const reviewerIsReceiver = computed(() => - props.activeMessage?.to === "reviewer" && props.phase === "receiver", -); -const reviewerIsActive = computed(() => reviewerIsSender.value || reviewerIsReceiver.value); -const reviewerBubbleText = computed(() => { - if (!props.activeMessage || props.reducedMotion) return null; - if (props.activeMessage.from === "reviewer" && (props.phase === "sender" || props.phase === "packet")) { - return props.activeMessage.text; - } - if (props.activeMessage.to === "reviewer" && props.phase === "receiver") { - return props.activeMessage.response; - } - return null; -}); diff --git a/landing/components/hero/CyberHeroRobot.vue b/landing/components/hero/CyberHeroRobot.vue index a19f03ad..d6547404 100644 --- a/landing/components/hero/CyberHeroRobot.vue +++ b/landing/components/hero/CyberHeroRobot.vue @@ -7,10 +7,12 @@ const props = defineProps<{ activeReceiver?: HeroAgentRole | "video" | null; }>(); +const { locale } = useI18n(); const isSender = computed(() => props.activeSender === props.agent.id); const isReceiver = computed(() => props.activeReceiver === props.agent.id); const imageLoading = computed(() => (props.agent.priority ? "eager" : "lazy")); const imageFetchPriority = computed(() => (props.agent.priority ? "high" : "auto")); +const statusLabel = computed(() => locale.value === "ru" ? "Статус:" : "Status:"); const rootStyle = computed(() => ({ "--agent-x": String(props.agent.desktop.x), @@ -62,7 +64,7 @@ const rootStyle = computed(() => ({
  • {{ task }}
  • - Status: + {{ statusLabel }} {{ agent.status }}
    diff --git a/landing/components/hero/CyberHeroScene.vue b/landing/components/hero/CyberHeroScene.vue index 3f97b340..3df6650a 100644 --- a/landing/components/hero/CyberHeroScene.vue +++ b/landing/components/hero/CyberHeroScene.vue @@ -1,11 +1,14 @@ +