diff --git a/lib/src/features/workbench/application/background_setup_jobs.dart b/lib/src/features/workbench/application/background_setup_jobs.dart index 13e474702..92c9fbc6e 100644 --- a/lib/src/features/workbench/application/background_setup_jobs.dart +++ b/lib/src/features/workbench/application/background_setup_jobs.dart @@ -172,6 +172,8 @@ class BackgroundSetupJobs extends _$BackgroundSetupJobs issueUrl: issueUrl, ), launchAgent: runtime.launchAgent, + assignSection: (workspaceId, sectionId) => + controller.saveWorkspaceSection(workspaceId, sectionId: sectionId), onPhase: (phase) => _setPhase(id, phase), ); try { diff --git a/lib/src/features/workbench/application/background_setup_jobs.g.dart b/lib/src/features/workbench/application/background_setup_jobs.g.dart index 435b066a1..80673f28c 100644 --- a/lib/src/features/workbench/application/background_setup_jobs.g.dart +++ b/lib/src/features/workbench/application/background_setup_jobs.g.dart @@ -42,7 +42,7 @@ final class BackgroundSetupJobsProvider } String _$backgroundSetupJobsHash() => - r'6b4bcfbc56013c02682e1eced2e8572474a16bdf'; + r'49510c21ff1c81c7282ed71aa8f76e3f59d91518'; abstract class _$BackgroundSetupJobs extends $Notifier { diff --git a/lib/src/features/workbench/application/prompt_workspace_pipeline.dart b/lib/src/features/workbench/application/prompt_workspace_pipeline.dart index 005403fa4..132aa70ae 100644 --- a/lib/src/features/workbench/application/prompt_workspace_pipeline.dart +++ b/lib/src/features/workbench/application/prompt_workspace_pipeline.dart @@ -29,6 +29,7 @@ class const PromptWorkspacePipeline({ required String operationId, required String projectId, required String prompt, + required bool autoAssignSection, }) generateIdentity, required final Future Function(Project project, String branchName) @@ -52,6 +53,8 @@ class const PromptWorkspacePipeline({ required bool requireIdempotency, }) launchAgent, + final Future Function(String workspaceId, String sectionId)? + assignSection, final void Function(String phase)? onPhase, final String Function()? createOperationId, }) { @@ -70,6 +73,7 @@ class const PromptWorkspacePipeline({ operationId: createOperationId?.call() ?? const Uuid().v4(), projectId: request.project.id, prompt: identityPrompt, + autoAssignSection: request.autoAssignSection, ); onPhase?.call('Checking generated branch'); final collision = @@ -93,6 +97,16 @@ class const PromptWorkspacePipeline({ hostId: request.hostId, issueUrl: request.issueUrl, ); + final sectionId = request.autoAssignSection ? identity.sectionId : null; + final sectionAssignment = assignSection; + if (sectionId != null && sectionAssignment != null) { + try { + await sectionAssignment(creation.workspace.id, sectionId); + } catch (_) { + // Section assignment is best-effort: the workspace itself was + // already created, so a failure must not fail the flow. + } + } break; } catch (error) { if (attempt == 0 && _looksLikeCollision(error)) { diff --git a/lib/src/features/workbench/domain/background_setup_job.dart b/lib/src/features/workbench/domain/background_setup_job.dart index 250fe5285..d863003c3 100644 --- a/lib/src/features/workbench/domain/background_setup_job.dart +++ b/lib/src/features/workbench/domain/background_setup_job.dart @@ -36,6 +36,7 @@ class const PromptWorkspaceCreateRequest({ final String? parentWorkspaceId, final String? hostId, final String? issueUrl, + final bool autoAssignSection = false, final WorkspaceCreationResult? created, final String? clientMutationId, final bool? originalLaunchWasIdempotent, @@ -56,6 +57,7 @@ class const PromptWorkspaceCreateRequest({ parentWorkspaceId: parentWorkspaceId, hostId: hostId, issueUrl: issueUrl, + autoAssignSection: autoAssignSection, created: created, clientMutationId: clientMutationId ?? this.clientMutationId, originalLaunchWasIdempotent: diff --git a/lib/src/features/workbench/infra/prompt_workspace_runtime_client.dart b/lib/src/features/workbench/infra/prompt_workspace_runtime_client.dart index 08df5ab66..e735b9b21 100644 --- a/lib/src/features/workbench/infra/prompt_workspace_runtime_client.dart +++ b/lib/src/features/workbench/infra/prompt_workspace_runtime_client.dart @@ -3,6 +3,7 @@ import 'package:alera/src/features/workbench/infra/terminal_host/terminal_host_p class const GeneratedWorkspaceIdentity({ required final String workspaceName, required final String branchName, + final String? sectionId, }); class const AgentProfileLaunchResult({ @@ -27,6 +28,7 @@ class PromptWorkspaceRuntimeClient( required String projectId, required String prompt, String? tabId, + bool autoAssignSection = false, }) async { await beforeAccess?.call(); final payload = _asMap( @@ -37,6 +39,9 @@ class PromptWorkspaceRuntimeClient( 'projectId': projectId, 'prompt': prompt, 'tabId': ?tabId, + // Additive: an older host ignores the flag and simply omits + // sectionId from the response. + 'autoAssignSection': autoAssignSection, }, const Duration(minutes: 11), ), @@ -44,6 +49,7 @@ class PromptWorkspaceRuntimeClient( return GeneratedWorkspaceIdentity( workspaceName: _requiredString(payload, 'workspaceName'), branchName: _requiredString(payload, 'branchName'), + sectionId: _optionalString(payload, 'sectionId'), ); } @@ -129,3 +135,11 @@ String _requiredString(Map value, String key) { } throw FormatException('Runtime response is missing "$key".'); } + +String? _optionalString(Map value, String key) { + final field = value[key]; + if (field is String && field.trim().isNotEmpty) { + return field; + } + return null; +} diff --git a/lib/src/features/workbench/presentation/prompt_workspace_dialog.dart b/lib/src/features/workbench/presentation/prompt_workspace_dialog.dart index a39b7746c..8d4dd2c49 100644 --- a/lib/src/features/workbench/presentation/prompt_workspace_dialog.dart +++ b/lib/src/features/workbench/presentation/prompt_workspace_dialog.dart @@ -57,6 +57,7 @@ class const PromptWorkspaceDialog({ required String operationId, required String projectId, required String prompt, + required bool autoAssignSection, }) generateIdentity, required final Future Function(String operationId) cancelGeneration, @@ -102,6 +103,10 @@ class const PromptWorkspaceDialog({ final String? initialError, final NewWorkspaceMode initialMode = .fromPrompt, final Widget? manualForm, + final bool hasWorkspaceSections = false, + final bool initialAutoAssignSection = true, + final Future Function(String workspaceId, String sectionId)? + assignSection, }) extends StatefulWidget { @override State createState() => _PromptWorkspaceDialogState(); @@ -129,11 +134,16 @@ class _PromptWorkspaceDialogState extends State { String? _agentLaunchMutationId; bool? _originalAgentLaunchWasIdempotent; bool _createAnother = false; + bool _autoAssignSection = true; bool _useProjectCheckout = false; + bool get _autoAssignSectionEffective => + widget.hasWorkspaceSections && _autoAssignSection; + @override void initState() { super.initState(); + _autoAssignSection = widget.initialAutoAssignSection; _useProjectCheckout = widget.enqueuePrompt != null && widget.initialUseProjectCheckout; _mode = widget.initialMode; @@ -295,6 +305,7 @@ class _PromptWorkspaceDialogState extends State { parentWorkspaceId: _selectedParentWorkspaceId, hostId: _selectedHostId, issueUrl: _linkedIssueUrl(), + autoAssignSection: _autoAssignSectionEffective, ), ); if (done == null) { @@ -353,6 +364,7 @@ class _PromptWorkspaceDialogState extends State { operationId: operationId, projectId: project.id, prompt: identityPrompt, + autoAssignSection: _autoAssignSectionEffective, ); } finally { if (_activeOperationId == operationId) { @@ -388,6 +400,18 @@ class _PromptWorkspaceDialogState extends State { hostId: _selectedHostId, issueUrl: _linkedIssueUrl(), ); + final sectionId = _autoAssignSectionEffective + ? identity.sectionId + : null; + final assignSection = widget.assignSection; + if (sectionId != null && assignSection != null) { + try { + await assignSection(creation.workspace.id, sectionId); + } catch (_) { + // Section assignment is best-effort: the workspace itself was + // already created, so a failure must not fail the flow. + } + } break; } catch (error) { if (attempt == 0 && _looksLikeCollision(error)) { @@ -444,42 +468,6 @@ class _PromptWorkspaceDialogState extends State { message.contains('workspace for branch'); } - Future _cancelGeneration() async { - final operationId = _activeOperationId; - if (operationId != null) { - await widget.cancelGeneration(operationId); - } - } - - Future _finishCreation( - WorkspaceCreationResult creation, - String agentTabId, - ) async { - if (!_createAnother) { - Navigator.of(context).pop( - PromptWorkspaceDialogResult(creation: creation, agentTabId: agentTabId), - ); - return; - } - await widget.onCreateAnother?.call( - creation: creation, - agentTabId: agentTabId, - ); - if (!mounted) { - return; - } - _promptController.clear(); - _issueUrlController.clear(); - _agentLaunchMutationId = null; - _originalAgentLaunchWasIdempotent = null; - setState(() { - _working = false; - _phase = null; - _error = null; - _created = null; - }); - } - void _selectMode(NewWorkspaceMode mode) { setState(() => _mode = mode); } diff --git a/lib/src/features/workbench/presentation/prompt_workspace_dialog_agent_launch.dart b/lib/src/features/workbench/presentation/prompt_workspace_dialog_agent_launch.dart index 2b547730a..dca2f4333 100644 --- a/lib/src/features/workbench/presentation/prompt_workspace_dialog_agent_launch.dart +++ b/lib/src/features/workbench/presentation/prompt_workspace_dialog_agent_launch.dart @@ -49,4 +49,40 @@ extension _PromptWorkspaceDialogAgentLaunch on _PromptWorkspaceDialogState { } } } + + Future _cancelGeneration() async { + final operationId = _activeOperationId; + if (operationId != null) { + await widget.cancelGeneration(operationId); + } + } + + Future _finishCreation( + WorkspaceCreationResult creation, + String agentTabId, + ) async { + if (!_createAnother) { + Navigator.of(context).pop( + PromptWorkspaceDialogResult(creation: creation, agentTabId: agentTabId), + ); + return; + } + await widget.onCreateAnother?.call( + creation: creation, + agentTabId: agentTabId, + ); + if (!mounted) { + return; + } + _promptController.clear(); + _issueUrlController.clear(); + _agentLaunchMutationId = null; + _originalAgentLaunchWasIdempotent = null; + _update(() { + _working = false; + _phase = null; + _error = null; + _created = null; + }); + } } diff --git a/lib/src/features/workbench/presentation/prompt_workspace_dialog_form.dart b/lib/src/features/workbench/presentation/prompt_workspace_dialog_form.dart index 02aca5238..85e2ed689 100644 --- a/lib/src/features/workbench/presentation/prompt_workspace_dialog_form.dart +++ b/lib/src/features/workbench/presentation/prompt_workspace_dialog_form.dart @@ -169,13 +169,28 @@ extension _PromptWorkspaceDialogForm on _PromptWorkspaceDialogState { ), ], const SizedBox(height: AleraTokens.space12), - AleraCheckbox( - value: _createAnother, - enabled: !_working && created == null, - onChanged: (value) { - _update(() => _createAnother = value); - }, - label: 'Create Another', + Row( + children: [ + if (widget.hasWorkspaceSections) ...[ + AleraCheckbox( + value: _autoAssignSection, + enabled: !_working && created == null, + onChanged: (value) { + _update(() => _autoAssignSection = value); + }, + label: 'Auto Assign Section', + ), + const SizedBox(width: AleraTokens.space12), + ], + AleraCheckbox( + value: _createAnother, + enabled: !_working && created == null, + onChanged: (value) { + _update(() => _createAnother = value); + }, + label: 'Create Another', + ), + ], ), const SizedBox(height: AleraTokens.space20), Row( diff --git a/lib/src/features/workbench/presentation/workbench_dialog_launchers_create_workspace.dart b/lib/src/features/workbench/presentation/workbench_dialog_launchers_create_workspace.dart index eeb09fc12..884e6758f 100644 --- a/lib/src/features/workbench/presentation/workbench_dialog_launchers_create_workspace.dart +++ b/lib/src/features/workbench/presentation/workbench_dialog_launchers_create_workspace.dart @@ -107,6 +107,9 @@ Future _showCreateWorkspaceDialogs( String? remainingRetryJobId, }) async { var boundJobId = remainingRetryJobId; + final workbenchState = ref.read(workbenchControllerProvider); + final hasWorkspaceSections = + workbenchState.supportsSections && workbenchState.sections.isNotEmpty; final promptResult = await showDialog( context: context, builder: (_) => PromptWorkspaceDialog( @@ -183,6 +186,10 @@ Future _showCreateWorkspaceDialogs( for (final candidate in parentCandidates) candidate.workspace, ], initialUseProjectCheckout: retryPrompt?.useProjectCheckout ?? true, + hasWorkspaceSections: hasWorkspaceSections, + initialAutoAssignSection: retryPrompt?.autoAssignSection ?? true, + assignSection: (workspaceId, sectionId) => + controller.saveWorkspaceSection(workspaceId, sectionId: sectionId), generateIdentity: runtime.generateIdentity, cancelGeneration: runtime.cancel, createWorkspace: diff --git a/mobile/lib/src/features/runtime/domain/agent_profile_summary.dart b/mobile/lib/src/features/runtime/domain/agent_profile_summary.dart index 467adc1f5..cd4f175a4 100644 --- a/mobile/lib/src/features/runtime/domain/agent_profile_summary.dart +++ b/mobile/lib/src/features/runtime/domain/agent_profile_summary.dart @@ -21,6 +21,7 @@ class const AgentProfileSummary({ class const GeneratedWorkspaceIdentity({ required final String workspaceName, required final String branchName, + final String? sectionId, }); class const AgentProfileLaunchResult({ diff --git a/mobile/lib/src/features/runtime/domain/runtime_client_surfaces.dart b/mobile/lib/src/features/runtime/domain/runtime_client_surfaces.dart index 6fd01f45b..10a786593 100644 --- a/mobile/lib/src/features/runtime/domain/runtime_client_surfaces.dart +++ b/mobile/lib/src/features/runtime/domain/runtime_client_surfaces.dart @@ -233,6 +233,7 @@ abstract interface class MobileWorkspaceClient { required String operationId, required String projectId, required String prompt, + bool autoAssignSection = false, }); Future cancelWorkspaceIdentity(String operationId); Future uploadPromptImage({ diff --git a/mobile/lib/src/features/runtime/infra/mobile_runtime_workspace_client.dart b/mobile/lib/src/features/runtime/infra/mobile_runtime_workspace_client.dart index 1b67f038d..4b5fccf85 100644 --- a/mobile/lib/src/features/runtime/infra/mobile_runtime_workspace_client.dart +++ b/mobile/lib/src/features/runtime/infra/mobile_runtime_workspace_client.dart @@ -299,6 +299,7 @@ mixin MobileRuntimeWorkspaceClient { required String operationId, required String projectId, required String prompt, + bool autoAssignSection = false, }) async { final payload = await requestMap( 'aiText.workspaceIdentity.generate', @@ -306,12 +307,16 @@ mixin MobileRuntimeWorkspaceClient { 'operationId': operationId, 'projectId': projectId, 'prompt': prompt, + // Additive: an older host ignores the flag and simply omits + // sectionId from the response. + 'autoAssignSection': autoAssignSection, }, const Duration(minutes: 11), ); return GeneratedWorkspaceIdentity( workspaceName: payload.requiredString('workspaceName'), branchName: payload.requiredString('branchName'), + sectionId: payload.optionalString('sectionId'), ); } diff --git a/mobile/lib/src/features/workbench/application/prompt_workspace_pipeline.dart b/mobile/lib/src/features/workbench/application/prompt_workspace_pipeline.dart index 36558a093..1cf68cf73 100644 --- a/mobile/lib/src/features/workbench/application/prompt_workspace_pipeline.dart +++ b/mobile/lib/src/features/workbench/application/prompt_workspace_pipeline.dart @@ -3,6 +3,7 @@ import 'package:alera_mobile/src/features/runtime/domain/runtime_client_surfaces import 'package:alera_mobile/src/features/runtime/domain/workspace_creation_result.dart'; import 'package:alera_mobile/src/features/workbench/application/deferred_workspace_setup_launcher.dart'; import 'package:alera_mobile/src/features/workbench/domain/background_setup_job.dart'; +import 'package:logging/logging.dart'; class PromptWorkspaceLaunchException implements Exception { PromptWorkspaceLaunchException({ @@ -74,6 +75,7 @@ Future runPromptWorkspaceCreate({ operationId: operationId, projectId: request.projectId, prompt: identityPrompt, + autoAssignSection: request.autoAssignSection, ); } finally { onOperationId?.call(null); @@ -110,6 +112,23 @@ Future runPromptWorkspaceCreate({ issueUrl: request.issueUrl, ); creation = created; + final sectionId = request.autoAssignSection ? identity.sectionId : null; + final sectionClient = client is MobileWorkspaceSectionClient + ? client as MobileWorkspaceSectionClient + : null; + if (sectionId != null && sectionClient != null) { + try { + await sectionClient.setWorkspaceSection( + created.workspace.id, + sectionId, + ); + } catch (error, stack) { + // Section assignment is best-effort: the workspace itself was + // already created, so a failure must not fail the flow. + Logger('PromptWorkspacePipeline') + .warning('Could not assign workspace section', error, stack); + } + } final parentId = request.parentWorkspaceId?.trim(); if (parentId != null && parentId.isNotEmpty) { try { diff --git a/mobile/lib/src/features/workbench/domain/background_setup_job.dart b/mobile/lib/src/features/workbench/domain/background_setup_job.dart index 20ab54c57..63229f5cd 100644 --- a/mobile/lib/src/features/workbench/domain/background_setup_job.dart +++ b/mobile/lib/src/features/workbench/domain/background_setup_job.dart @@ -37,6 +37,7 @@ class const PromptWorkspaceCreateRequest({ final bool useProjectCheckout = false, final String? parentWorkspaceId, final String? issueUrl, + final bool autoAssignSection = false, final WorkspaceCreationResult? created, final String? clientMutationId, final bool? originalLaunchWasIdempotent, @@ -60,6 +61,7 @@ class const PromptWorkspaceCreateRequest({ useProjectCheckout: useProjectCheckout, parentWorkspaceId: parentWorkspaceId, issueUrl: issueUrl, + autoAssignSection: autoAssignSection, created: created, clientMutationId: clientMutationId ?? this.clientMutationId, originalLaunchWasIdempotent: diff --git a/mobile/lib/src/features/workbench/presentation/background_setup_job_host.dart b/mobile/lib/src/features/workbench/presentation/background_setup_job_host.dart index 61d4b9530..08388bf2e 100644 --- a/mobile/lib/src/features/workbench/presentation/background_setup_job_host.dart +++ b/mobile/lib/src/features/workbench/presentation/background_setup_job_host.dart @@ -120,6 +120,7 @@ Future _openRetryForm( list.supportsSharedCheckoutWorkspaces, projects: list.projects, workspaces: list.workspaces, + sections: list.sections, defaultAgentProfileId: request.profileId, supportsPromptWorkspaceCreation: list.supportsPromptWorkspaceCreation, supportsPromptImageUpload: list.supportsPromptImageUpload, @@ -138,6 +139,7 @@ Future _openRetryForm( initialParentWorkspaceId: request.parentWorkspaceId, supportsLinkedIssues: linkedIssues, initialIssueUrl: request.issueUrl, + initialAutoAssignSection: request.autoAssignSection, ), _ => CreateWorkspaceScreen( hostId: hostId, diff --git a/mobile/lib/src/features/workbench/presentation/create_workspace_prompt.dart b/mobile/lib/src/features/workbench/presentation/create_workspace_prompt.dart index 3cd9327f5..ce8cafa07 100644 --- a/mobile/lib/src/features/workbench/presentation/create_workspace_prompt.dart +++ b/mobile/lib/src/features/workbench/presentation/create_workspace_prompt.dart @@ -204,6 +204,20 @@ extension _CreateWorkspacePromptForm on _CreateWorkspaceScreenState { ), ], const SizedBox(height: AleraTokens.spaceMd), + if (widget.sections.isNotEmpty) + CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: .leading, + value: _autoAssignSection, + onChanged: + promptState.loading || created != null || _uploadingAttachment + ? null + : (value) { + _update(() => _autoAssignSection = value ?? false); + }, + title: const Text('Auto Assign Section'), + subtitle: const Text('Pick the section that fits the prompt'), + ), CheckboxListTile( contentPadding: EdgeInsets.zero, controlAffinity: .leading, @@ -357,6 +371,7 @@ extension _CreateWorkspacePromptForm on _CreateWorkspaceScreenState { useProjectCheckout: _useProjectCheckout, parentWorkspaceId: _promptParentWorkspaceId, issueUrl: _linkedIssueUrl(), + autoAssignSection: widget.sections.isNotEmpty && _autoAssignSection, ), jobId: jobId, ); diff --git a/mobile/lib/src/features/workbench/presentation/create_workspace_screen.dart b/mobile/lib/src/features/workbench/presentation/create_workspace_screen.dart index b1f00abc8..508aeb79c 100644 --- a/mobile/lib/src/features/workbench/presentation/create_workspace_screen.dart +++ b/mobile/lib/src/features/workbench/presentation/create_workspace_screen.dart @@ -15,6 +15,7 @@ import 'package:alera_mobile/src/features/linked_issues/presentation/mobile_issu import 'package:alera_mobile/src/features/runtime/domain/project_selection_order.dart'; import 'package:alera_mobile/src/features/runtime/domain/project_summary.dart'; import 'package:alera_mobile/src/features/runtime/domain/workspace_creation_result.dart'; +import 'package:alera_mobile/src/features/runtime/domain/workspace_section_summary.dart'; import 'package:alera_mobile/src/features/runtime/domain/workspace_summary.dart'; import 'package:alera_mobile/src/features/workbench/application/background_setup_jobs.dart'; import 'package:alera_mobile/src/features/workbench/application/workbench_providers.dart'; @@ -43,6 +44,8 @@ class const CreateWorkspaceScreen({ final String? initialCheckoutHostId, required final List projects, required final List workspaces, + final List sections = const [], + final bool initialAutoAssignSection = true, final String? defaultAgentProfileId, final bool supportsPromptWorkspaceCreation = true, final bool supportsPromptImageUpload = false, @@ -96,6 +99,7 @@ class _CreateWorkspaceScreenState extends ConsumerState { String? _promptParentWorkspaceId; bool _reuseExistingBranch = false; bool _useProjectCheckout = true; + bool _autoAssignSection = true; bool _createAnother = false; bool _creating = false; bool _loadingBranches = false; @@ -165,6 +169,7 @@ class _CreateWorkspaceScreenState extends ConsumerState { _retryJobId = widget.retryJobId; _applyRetryHydration = widget.initialProjectId != null; _jobs = ref.read(backgroundSetupJobsProvider.notifier); + _autoAssignSection = widget.initialAutoAssignSection; _reuseExistingBranch = widget.initialReuseExistingBranch; _useProjectCheckout = widget.supportsSharedCheckoutWorkspaces && diff --git a/mobile/lib/src/features/workbench/presentation/runtime_workspaces_screen.dart b/mobile/lib/src/features/workbench/presentation/runtime_workspaces_screen.dart index 99536ec2b..a747df062 100644 --- a/mobile/lib/src/features/workbench/presentation/runtime_workspaces_screen.dart +++ b/mobile/lib/src/features/workbench/presentation/runtime_workspaces_screen.dart @@ -164,6 +164,7 @@ class const RuntimeWorkspacesScreen({ data.value!.supportsSharedCheckoutWorkspaces, projects: data.value!.projects, workspaces: data.value!.workspaces, + sections: data.value!.sections, defaultAgentProfileId: data.value!.defaultAgentProfileId, supportsPromptWorkspaceCreation: data.value!.supportsPromptWorkspaceCreation, diff --git a/mobile/test/create_workspace_screen_test.dart b/mobile/test/create_workspace_screen_test.dart index 3083ea035..b932493ba 100644 --- a/mobile/test/create_workspace_screen_test.dart +++ b/mobile/test/create_workspace_screen_test.dart @@ -4,6 +4,7 @@ import 'package:alera_mobile/src/app/app_navigation.dart'; import 'package:alera_mobile/src/features/ai_dictation/application/mobile_ai_dictation_settings_controller.dart'; import 'package:alera_mobile/src/features/ai_dictation/domain/mobile_ai_dictation_settings.dart'; import 'package:alera_mobile/src/features/runtime/domain/project_summary.dart'; +import 'package:alera_mobile/src/features/runtime/domain/workspace_section_summary.dart'; import 'package:alera_mobile/src/features/runtime/domain/workspace_summary.dart'; import 'package:alera_mobile/src/features/terminal/application/terminal_providers.dart'; import 'package:alera_mobile/src/features/workbench/application/workbench_providers.dart'; @@ -18,9 +19,11 @@ import 'support/fake_ai_dictation_settings.dart'; import 'support/fake_terminal_client.dart'; part 'create_workspace_shortcut_test_cases.dart'; +part 'create_workspace_section_assignment_test_cases.dart'; void main() { _registerCreateWorkspaceShortcutTests(); + _registerCreateWorkspaceSectionAssignmentTests(); testWidgets('Create Another keeps the mobile form open and resets it', ( tester, ) async { diff --git a/mobile/test/create_workspace_section_assignment_test_cases.dart b/mobile/test/create_workspace_section_assignment_test_cases.dart new file mode 100644 index 000000000..6a4d5f175 --- /dev/null +++ b/mobile/test/create_workspace_section_assignment_test_cases.dart @@ -0,0 +1,96 @@ +part of 'create_workspace_screen_test.dart'; + +void _registerCreateWorkspaceSectionAssignmentTests() { + testWidgets('Auto Assign Section hides without sections', (tester) async { + final client = FakeTerminalClient() + ..projectBranches = const ['main']; + addTearDown(client.dispose); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + workspaceClientProvider('host-1').overrideWith((ref) async => client), + terminalClientProvider('host-1').overrideWith((ref) async => client), + ], + child: MaterialApp( + navigatorKey: aleraNavigatorKey, + home: const CreateWorkspaceScreen( + hostId: 'host-1', + projects: [ + ProjectSummary( + id: 'project-1', + name: 'Alera', + repoPath: '/repo/alera', + ), + ], + workspaces: [], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible( + find.text('Create Another'), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + expect(find.text('Auto Assign Section'), findsNothing); + expect(find.text('Create Another'), findsOneWidget); + }); + + testWidgets('Auto Assign Section defaults on when sections exist', ( + tester, + ) async { + final client = FakeTerminalClient() + ..projectBranches = const ['main']; + addTearDown(client.dispose); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + workspaceClientProvider('host-1').overrideWith((ref) async => client), + terminalClientProvider('host-1').overrideWith((ref) async => client), + ], + child: MaterialApp( + navigatorKey: aleraNavigatorKey, + home: CreateWorkspaceScreen( + hostId: 'host-1', + projects: const [ + ProjectSummary( + id: 'project-1', + name: 'Alera', + repoPath: '/repo/alera', + ), + ], + workspaces: const [], + sections: [ + WorkspaceSectionSummary( + id: 'section-1', + name: 'Work', + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible( + find.text('Auto Assign Section'), + 200, + scrollable: find.byType(Scrollable).first, + ); + expect( + tester + .widget( + find.widgetWithText(CheckboxListTile, 'Auto Assign Section'), + ) + .value, + isTrue, + ); + }); +} diff --git a/mobile/test/prompt_workspace_section_assignment_test.dart b/mobile/test/prompt_workspace_section_assignment_test.dart new file mode 100644 index 000000000..6d03f8925 --- /dev/null +++ b/mobile/test/prompt_workspace_section_assignment_test.dart @@ -0,0 +1,102 @@ +import 'package:alera_mobile/src/features/runtime/domain/agent_profile_summary.dart'; +import 'package:alera_mobile/src/features/runtime/domain/runtime_client_surfaces.dart'; +import 'package:alera_mobile/src/features/runtime/domain/workspace_section_summary.dart'; +import 'package:alera_mobile/src/features/workbench/application/prompt_workspace_pipeline.dart'; +import 'package:alera_mobile/src/features/workbench/domain/background_setup_job.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/fake_terminal_client.dart'; + +class _SectionFakeClient extends FakeTerminalClient + implements MobileWorkspaceSectionClient { + final List sectionAssignments = []; + bool failSectionAssignment = false; + + @override + bool get supportsWorkspaceSections => true; + + @override + Future> listWorkspaceSections() async => + const []; + + @override + Future createWorkspaceSection( + String name, + String workspaceId, + ) async => throw UnimplementedError(); + + @override + Future setWorkspaceSection( + String workspaceId, + String? sectionId, + ) async { + if (failSectionAssignment) { + throw StateError('Section no longer exists'); + } + sectionAssignments.add('$workspaceId $sectionId'); + } + + @override + Future removeWorkspaceSection(String sectionId) async {} +} + +void main() { + test('assigns the generated section after creation', () async { + final client = _SectionFakeClient() + ..projectBranches = const ['main'] + ..generatedWorkspaceIdentity = const GeneratedWorkspaceIdentity( + workspaceName: 'Generated Workspace', + branchName: 'feat/generated-workspace', + sectionId: 'section-1', + ); + addTearDown(client.dispose); + final outcome = await runPromptWorkspaceCreate( + client: client, + loadTerminalClient: () async => client, + request: const PromptWorkspaceCreateRequest( + hostId: 'host', + projectId: 'project', + prompt: 'Build the feature', + sourceBranch: 'main', + profileId: 'profile-1', + workspaceBranches: {}, + autoAssignSection: true, + ), + clientMutationId: 'section-test', + ); + + expect(client.lastGenerateWorkspaceIdentityAutoAssign, isTrue); + expect(client.sectionAssignments, ['created section-1']); + expect(outcome.creation.workspace.id, 'created'); + }); + + test('a section assignment failure does not fail the flow', () async { + final client = _SectionFakeClient() + ..projectBranches = const ['main'] + ..failSectionAssignment = true + ..generatedWorkspaceIdentity = const GeneratedWorkspaceIdentity( + workspaceName: 'Generated Workspace', + branchName: 'feat/generated-workspace', + sectionId: 'section-1', + ); + addTearDown(client.dispose); + final outcome = await runPromptWorkspaceCreate( + client: client, + loadTerminalClient: () async => client, + request: const PromptWorkspaceCreateRequest( + hostId: 'host', + projectId: 'project', + prompt: 'Build the feature', + sourceBranch: 'main', + profileId: 'profile-1', + workspaceBranches: {}, + autoAssignSection: true, + ), + clientMutationId: 'section-failure-test', + ); + + expect(client.sectionAssignments, isEmpty); + expect(outcome.creation.workspace.id, 'created'); + expect(outcome.agentTabId, 'agent-tab'); + }); +} diff --git a/mobile/test/support/fake_workspace_lifecycle_client.dart b/mobile/test/support/fake_workspace_lifecycle_client.dart index f2ee2ab1f..84ff0479e 100644 --- a/mobile/test/support/fake_workspace_lifecycle_client.dart +++ b/mobile/test/support/fake_workspace_lifecycle_client.dart @@ -27,6 +27,7 @@ mixin FakeWorkspaceLifecycleClient { Future? listAgentProfilesDelay; Future? generateWorkspaceIdentityDelay; Object? listAgentProfilesError; + bool? lastGenerateWorkspaceIdentityAutoAssign; Future workspaceSidebarSnapshot() async { return const WorkspaceSidebarSnapshot( @@ -82,8 +83,10 @@ mixin FakeWorkspaceLifecycleClient { required String operationId, required String projectId, required String prompt, + bool autoAssignSection = false, }) async { calls.add('generateWorkspaceIdentity $projectId'); + lastGenerateWorkspaceIdentityAutoAssign = autoAssignSection; final delay = generateWorkspaceIdentityDelay; if (delay != null) { await delay; diff --git a/mobile/test/workspace_list_controller_test_harness.dart b/mobile/test/workspace_list_controller_test_harness.dart index 3702c193e..6f74a0343 100644 --- a/mobile/test/workspace_list_controller_test_harness.dart +++ b/mobile/test/workspace_list_controller_test_harness.dart @@ -112,6 +112,7 @@ class _FakeWorkspaceClient() required String operationId, required String projectId, required String prompt, + bool autoAssignSection = false, }) async { return const GeneratedWorkspaceIdentity( workspaceName: 'Generated Workspace', diff --git a/rust/alera-cli/src/terminal_host/server/ai_assist_requests.rs b/rust/alera-cli/src/terminal_host/server/ai_assist_requests.rs index cf100ff77..6de2a1ea1 100644 --- a/rust/alera-cli/src/terminal_host/server/ai_assist_requests.rs +++ b/rust/alera-cli/src/terminal_host/server/ai_assist_requests.rs @@ -57,6 +57,12 @@ impl ServerActor { .get("tabId") .and_then(Value::as_str) .map(str::to_string); + // Additive: an older client never sends this flag and gets the + // original two-field identity prompt. + let auto_assign_section = payload + .get("autoAssignSection") + .and_then(Value::as_bool) + .unwrap_or(false); let project = self.runtime_store.clone(); let inbox = self.inbox.clone(); let (registration, cancel_rx) = active_generations().register(operation_id, None)?; @@ -71,6 +77,16 @@ impl ServerActor { .effective_ai_assist_settings() .await .map_err(|error| HostError::state(error.to_string()))?; + // Section assignment is best-effort: a sections lookup failure + // must not break identity generation. + let sections = if auto_assign_section { + project.list_workspace_sections().await.unwrap_or_else(|error| { + tracing::warn!("could not list workspace sections for identity generation: {error}"); + Vec::new() + }) + } else { + Vec::new() + }; let mut initial_prompt = initial_prompt; if let Some(tab_id) = tab_id { if let Some(tab) = project @@ -96,6 +112,7 @@ impl ServerActor { &initial_prompt, settings, cancel_rx, + §ions, ) .await } @@ -134,6 +151,7 @@ async fn generate_workspace_identity( initial_prompt: &str, settings: RuntimeAiAssistSettings, cancel_rx: oneshot::Receiver<()>, + sections: &[alera_core::runtime::WorkspaceSection], ) -> HostResult { if !settings.enabled { return Err(HostError::state("AI Assist is disabled.")); @@ -150,11 +168,12 @@ async fn generate_workspace_identity( .get("workspaceIdentity") .map(String::as_str) .unwrap_or_default(), + sections, ); let plan = plan_command(&settings, "workspaceIdentity", &prompt)?; let timeout_seconds = settings.timeout_seconds; let result = run_command(plan, working_directory, timeout_seconds, cancel_rx).await?; - parse_workspace_identity(&result) + parse_workspace_identity(&result, sections) } pub(super) fn plan_command( diff --git a/rust/alera-cli/src/terminal_host/server/ai_assist_workspace_identity.rs b/rust/alera-cli/src/terminal_host/server/ai_assist_workspace_identity.rs index 8b721a3c8..01cfbd7fc 100644 --- a/rust/alera-cli/src/terminal_host/server/ai_assist_workspace_identity.rs +++ b/rust/alera-cli/src/terminal_host/server/ai_assist_workspace_identity.rs @@ -1,3 +1,4 @@ +use alera_core::runtime::WorkspaceSection; use serde_json::{json, Value}; use crate::terminal_host::host_error::{HostError, HostResult}; @@ -31,31 +32,61 @@ pub(super) fn handoff_identity_context( ) } -pub(super) fn workspace_identity_prompt(initial_prompt: &str, custom_instructions: &str) -> String { - let mut sections = vec![ +pub(super) fn workspace_identity_prompt( + initial_prompt: &str, + custom_instructions: &str, + sections: &[WorkspaceSection], +) -> String { + let fields = if sections.is_empty() { + "workspaceName and branchName".to_string() + } else { + "workspaceName, branchName, and section".to_string() + }; + let mut lines = vec![ "Generate the identity for a new development workspace from the user's task.".to_string(), - "Return only one compact JSON object with exactly these string fields: workspaceName and branchName.".to_string(), + format!("Return only one compact JSON object with exactly these string fields: {fields}."), String::new(), "Rules:".to_string(), "- workspaceName: concise human-readable title, title case, 2 to 6 words.".to_string(), "- branchName: lowercase valid Git branch, use kebab-case, and start with feat/, fix/, chore/, docs/, refactor/, test/, or perf/.".to_string(), + ]; + if !sections.is_empty() { + lines.push( + "- section: the workspace section the task belongs to. Answer with exactly one of the section names listed below, or \"Others\" when none fits." + .to_string(), + ); + } + lines.extend([ "- Describe the requested outcome, not the implementation process.".to_string(), - "- Do not include markdown, explanations, quotes around the whole object, or extra fields.".to_string(), + "- Do not include markdown, explanations, quotes around the whole object, or extra fields." + .to_string(), + ]); + if !sections.is_empty() { + lines.push(String::new()); + lines.push("Sections:".to_string()); + for section in sections { + lines.push(format!("- {}", section.name)); + } + } + lines.extend([ String::new(), "User task:".to_string(), initial_prompt.trim().to_string(), - ]; + ]); if !custom_instructions.trim().is_empty() { - sections.extend([ + lines.extend([ String::new(), "Additional user instructions:".to_string(), custom_instructions.trim().to_string(), ]); } - sections.join("\n") + lines.join("\n") } -pub(super) fn parse_workspace_identity(raw: &str) -> HostResult { +pub(super) fn parse_workspace_identity( + raw: &str, + sections: &[WorkspaceSection], +) -> HostResult { let trimmed = raw.trim(); let unfenced = trimmed .strip_prefix("```json") @@ -90,38 +121,126 @@ pub(super) fn parse_workspace_identity(raw: &str) -> HostResult { "AI Assist returned an invalid Git branch name.", )); } - Ok(json!({ + let mut response = json!({ "workspaceName": workspace_name, "branchName": branch_name, - })) + }); + if !sections.is_empty() { + // The store keeps section names unique case-insensitively, so a + // case-insensitive match here cannot be ambiguous. A missing, unknown, + // or "Others" answer leaves the workspace unassigned. + let section_name = value + .get("section") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty() && value.len() <= 200); + if let Some(section) = section_name.and_then(|name| { + sections + .iter() + .find(|section| section.name.to_lowercase() == name.to_lowercase()) + }) { + response["sectionId"] = json!(section.id); + } + } + Ok(response) } #[cfg(test)] mod tests { use super::*; + use chrono::Utc; + + fn section(id: &str, name: &str) -> WorkspaceSection { + WorkspaceSection { + id: id.to_string(), + name: name.to_string(), + created_at: Utc::now(), + updated_at: Utc::now(), + } + } #[test] fn identity_prompt_includes_custom_workspace_instructions() { - let prompt = workspace_identity_prompt("Add offline mode", "Use fix/ branches."); + let prompt = workspace_identity_prompt("Add offline mode", "Use fix/ branches.", &[]); assert!(prompt.contains("Add offline mode")); assert!(prompt.contains("Use fix/ branches.")); assert!(prompt.contains("workspaceName")); } + #[test] + fn identity_prompt_without_sections_omits_the_section_rule() { + let prompt = workspace_identity_prompt("Add offline mode", "", &[]); + assert!(prompt.contains("workspaceName and branchName")); + assert!(!prompt.contains("Sections:")); + assert!(!prompt.contains("Others")); + } + + #[test] + fn identity_prompt_lists_sections_with_others_fallback() { + let sections = vec![section("a", "Work"), section("b", "Personal")]; + let prompt = workspace_identity_prompt("Add offline mode", "", §ions); + assert!(prompt.contains("workspaceName, branchName, and section")); + assert!(prompt.contains("Sections:\n- Work\n- Personal")); + assert!(prompt.contains("\"Others\" when none fits")); + } + #[test] fn parses_fenced_workspace_identity() { let value = parse_workspace_identity( "```json\n{\"workspaceName\":\"Offline Mode\",\"branchName\":\"feat/offline-mode\"}\n```", + &[], ) .unwrap(); assert_eq!(value["workspaceName"], "Offline Mode"); assert_eq!(value["branchName"], "feat/offline-mode"); + assert!(value.get("sectionId").is_none()); + } + + #[test] + fn resolves_a_returned_section_name_case_insensitively() { + let sections = vec![section("a", "Work"), section("b", "Personal")]; + let value = parse_workspace_identity( + "{\"workspaceName\":\"Offline Mode\",\"branchName\":\"feat/offline-mode\",\"section\":\"work\"}", + §ions, + ) + .unwrap(); + assert_eq!(value["sectionId"], "a"); + } + + #[test] + fn others_and_unknown_sections_leave_the_workspace_unassigned() { + let sections = vec![section("a", "Work")]; + for answer in ["Others", "unknown", ""] { + let value = parse_workspace_identity( + &format!("{{\"workspaceName\":\"Offline Mode\",\"branchName\":\"feat/offline-mode\",\"section\":\"{answer}\"}}"), + §ions, + ) + .unwrap(); + assert!(value.get("sectionId").is_none(), "answer: {answer}"); + } + let value = parse_workspace_identity( + "{\"workspaceName\":\"Offline Mode\",\"branchName\":\"feat/offline-mode\"}", + §ions, + ) + .unwrap(); + assert!(value.get("sectionId").is_none()); + } + + #[test] + fn ignores_a_returned_section_when_assignment_was_not_requested() { + let value = parse_workspace_identity( + "{\"workspaceName\":\"Offline Mode\",\"branchName\":\"feat/offline-mode\",\"section\":\"Work\"}", + &[], + ) + .unwrap(); + assert!(value.get("sectionId").is_none()); } #[test] fn rejects_invalid_generated_branch() { let result = parse_workspace_identity( "{\"workspaceName\":\"Offline Mode\",\"branchName\":\"invalid branch\"}", + &[], ); assert!(result.is_err()); } diff --git a/test/unit/background_setup_job_test.dart b/test/unit/background_setup_job_test.dart index c1ae59881..526737156 100644 --- a/test/unit/background_setup_job_test.dart +++ b/test/unit/background_setup_job_test.dart @@ -152,6 +152,7 @@ void main() { sourceBranch: 'main', parentWorkspaceId: 'parent-1', hostId: 'local', + autoAssignSection: true, clientMutationId: 'mut-1', originalLaunchWasIdempotent: true, setupStarted: false, @@ -163,6 +164,7 @@ void main() { setupStarted: true, ); expect(copied.created, created); + expect(copied.autoAssignSection, isTrue); expect(copied.clientMutationId, 'mut-2'); expect(copied.originalLaunchWasIdempotent, isFalse); expect(copied.setupStarted, isTrue); diff --git a/test/unit/prompt_workspace_pipeline_test.dart b/test/unit/prompt_workspace_pipeline_test.dart index 2eb12dd3a..4247aef53 100644 --- a/test/unit/prompt_workspace_pipeline_test.dart +++ b/test/unit/prompt_workspace_pipeline_test.dart @@ -27,11 +27,15 @@ void main() { final phases = []; final pipeline = PromptWorkspacePipeline( generateIdentity: - ({required operationId, required projectId, required prompt}) async => - const GeneratedWorkspaceIdentity( - workspaceName: 'Prompt Workspace', - branchName: 'feat/prompt-workspace', - ), + ({ + required operationId, + required projectId, + required prompt, + required autoAssignSection, + }) async => const GeneratedWorkspaceIdentity( + workspaceName: 'Prompt Workspace', + branchName: 'feat/prompt-workspace', + ), checkBranchExists: (_, _) async => false, workspaceBranches: (_) => const {}, createWorkspace: @@ -92,7 +96,12 @@ void main() { var attempts = 0; final pipeline = PromptWorkspacePipeline( generateIdentity: - ({required operationId, required projectId, required prompt}) async { + ({ + required operationId, + required projectId, + required prompt, + required autoAssignSection, + }) async { attempts += 1; return GeneratedWorkspaceIdentity( workspaceName: 'Prompt Workspace', @@ -150,11 +159,15 @@ void main() { test('launch failure keeps the created workspace', () async { final pipeline = PromptWorkspacePipeline( generateIdentity: - ({required operationId, required projectId, required prompt}) async => - const GeneratedWorkspaceIdentity( - workspaceName: 'Prompt Workspace', - branchName: 'feat/prompt-workspace', - ), + ({ + required operationId, + required projectId, + required prompt, + required autoAssignSection, + }) async => const GeneratedWorkspaceIdentity( + workspaceName: 'Prompt Workspace', + branchName: 'feat/prompt-workspace', + ), checkBranchExists: (_, _) async => false, workspaceBranches: (_) => const {}, createWorkspace: @@ -211,4 +224,156 @@ void main() { ), ); }); + + test('assigns the generated section after creation', () async { + var forwardedAutoAssign = false; + final assignments = []; + final pipeline = PromptWorkspacePipeline( + generateIdentity: + ({ + required operationId, + required projectId, + required prompt, + required autoAssignSection, + }) async { + forwardedAutoAssign = autoAssignSection; + return const GeneratedWorkspaceIdentity( + workspaceName: 'Prompt Workspace', + branchName: 'feat/prompt-workspace', + sectionId: 'section-1', + ); + }, + checkBranchExists: (_, _) async => false, + workspaceBranches: (_) => const {}, + createWorkspace: + ({ + required project, + required sourceBranch, + required newBranchName, + required name, + parentWorkspaceId, + hostId, + issueUrl, + }) async { + return WorkspaceCreationResult( + workspace: Workspace( + id: 'workspace-4', + projectId: project.id, + name: name, + branch: newBranchName, + path: '/repo/ws', + createdAt: now, + updatedAt: now, + kind: .linked, + status: .active, + sourceBranch: sourceBranch, + ), + setupReport: .empty, + ); + }, + assignSection: (workspaceId, sectionId) async { + assignments.add('$workspaceId $sectionId'); + }, + launchAgent: + ({ + required workspaceId, + required profileId, + required prompt, + required clientMutationId, + required requireIdempotency, + }) async => const AgentProfileLaunchResult( + tabId: 'tab-4', + agentType: 'codex', + profileId: 'profile-1', + idempotent: true, + ), + ); + + final result = await pipeline.run( + PromptWorkspaceCreateRequest( + project: project, + prompt: 'Build the feature', + profileId: 'profile-1', + sourceBranch: 'main', + autoAssignSection: true, + ), + ); + + expect(forwardedAutoAssign, isTrue); + expect(assignments, ['workspace-4 section-1']); + expect(result.creation.workspace.id, 'workspace-4'); + }); + + test('a section assignment failure does not fail the flow', () async { + final pipeline = PromptWorkspacePipeline( + generateIdentity: + ({ + required operationId, + required projectId, + required prompt, + required autoAssignSection, + }) async => const GeneratedWorkspaceIdentity( + workspaceName: 'Prompt Workspace', + branchName: 'feat/prompt-workspace', + sectionId: 'section-1', + ), + checkBranchExists: (_, _) async => false, + workspaceBranches: (_) => const {}, + createWorkspace: + ({ + required project, + required sourceBranch, + required newBranchName, + required name, + parentWorkspaceId, + hostId, + issueUrl, + }) async { + return WorkspaceCreationResult( + workspace: Workspace( + id: 'workspace-5', + projectId: project.id, + name: name, + branch: newBranchName, + path: '/repo/ws', + createdAt: now, + updatedAt: now, + kind: .linked, + status: .active, + sourceBranch: sourceBranch, + ), + setupReport: .empty, + ); + }, + assignSection: (_, _) async { + throw StateError('section gone'); + }, + launchAgent: + ({ + required workspaceId, + required profileId, + required prompt, + required clientMutationId, + required requireIdempotency, + }) async => const AgentProfileLaunchResult( + tabId: 'tab-5', + agentType: 'codex', + profileId: 'profile-1', + idempotent: true, + ), + ); + + final result = await pipeline.run( + PromptWorkspaceCreateRequest( + project: project, + prompt: 'Build the feature', + profileId: 'profile-1', + sourceBranch: 'main', + autoAssignSection: true, + ), + ); + + expect(result.creation.workspace.id, 'workspace-5'); + expect(result.agentTabId, 'tab-5'); + }); } diff --git a/test/widget/prompt_workspace_dialog_auto_assign_test_cases.dart b/test/widget/prompt_workspace_dialog_auto_assign_test_cases.dart new file mode 100644 index 000000000..fd0e2131a --- /dev/null +++ b/test/widget/prompt_workspace_dialog_auto_assign_test_cases.dart @@ -0,0 +1,242 @@ +part of 'prompt_workspace_dialog_test.dart'; + +void _registerPromptWorkspaceAutoAssignTests() { + testWidgets('hides Auto Assign Section without workspace sections', ( + tester, + ) async { + final now = DateTime.utc(2026, 7, 30); + final project = _project(id: 'project-1', name: 'Alera', now: now); + final profile = _profile(id: 'profile-1', name: 'Codex Builder', now: now); + + await tester.pumpWidget( + MaterialApp( + home: PromptWorkspaceDialog( + projects: [project], + agentProfiles: [profile], + loadBranches: (_) async => ['main'], + checkBranchExists: (_, _) async => false, + workspaceBranches: (_) => const {}, + parentWorkspaces: const [], + generateIdentity: + ({ + required operationId, + required projectId, + required prompt, + required autoAssignSection, + }) async => const GeneratedWorkspaceIdentity( + workspaceName: 'Prompt Workspace', + branchName: 'feat/prompt-workspace', + ), + cancelGeneration: (_) async {}, + createWorkspace: ({ + required project, + required sourceBranch, + required newBranchName, + required name, + parentWorkspaceId, + hostId, + issueUrl, + }) async => throw UnimplementedError(), + launchAgent: ({ + required workspaceId, + required profileId, + required prompt, + required clientMutationId, + required requireIdempotency, + }) async => throw UnimplementedError(), + supportsIdempotentAgentLaunch: () async => true, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Auto Assign Section'), findsNothing); + expect(find.text('Create Another'), findsOneWidget); + }); + + testWidgets('forwards the section choice and assigns it after creation', ( + tester, + ) async { + final now = DateTime.utc(2026, 7, 30); + final project = _project(id: 'project-1', name: 'Alera', now: now); + final profile = _profile(id: 'profile-1', name: 'Codex Builder', now: now); + var forwardedAutoAssign = false; + final assignments = []; + + await tester.pumpWidget( + MaterialApp( + home: PromptWorkspaceDialog( + projects: [project], + agentProfiles: [profile], + loadBranches: (_) async => ['main'], + checkBranchExists: (_, _) async => false, + workspaceBranches: (_) => const {}, + parentWorkspaces: const [], + generateIdentity: + ({ + required operationId, + required projectId, + required prompt, + required autoAssignSection, + }) async { + forwardedAutoAssign = autoAssignSection; + return const GeneratedWorkspaceIdentity( + workspaceName: 'Prompt Workspace', + branchName: 'feat/prompt-workspace', + sectionId: 'section-1', + ); + }, + cancelGeneration: (_) async {}, + createWorkspace: + ({ + required project, + required sourceBranch, + required newBranchName, + required name, + parentWorkspaceId, + hostId, + issueUrl, + }) async { + return WorkspaceCreationResult( + workspace: _workspace( + id: 'workspace-1', + projectId: project.id, + name: name, + branch: newBranchName, + kind: .linked, + now: now, + ), + setupReport: .empty, + ); + }, + launchAgent: + ({ + required workspaceId, + required profileId, + required prompt, + required clientMutationId, + required requireIdempotency, + }) async => AgentProfileLaunchResult( + tabId: 'tab-1', + agentType: profile.agentType, + profileId: profileId, + idempotent: true, + ), + supportsIdempotentAgentLaunch: () async => true, + hasWorkspaceSections: true, + assignSection: (workspaceId, sectionId) async { + assignments.add('$workspaceId $sectionId'); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Auto Assign Section'), findsOneWidget); + await tester.enterText( + find.widgetWithText(TextField, 'Initial Prompt'), + 'Build workspace creation', + ); + final submit = find.text('Create And Start Agent'); + await tester.ensureVisible(submit); + await tester.tap(submit); + await tester.pumpAndSettle(); + + expect(forwardedAutoAssign, isTrue); + expect(assignments, ['workspace-1 section-1']); + }); + + testWidgets('unchecking Auto Assign Section skips the assignment', ( + tester, + ) async { + final now = DateTime.utc(2026, 7, 30); + final project = _project(id: 'project-1', name: 'Alera', now: now); + final profile = _profile(id: 'profile-1', name: 'Codex Builder', now: now); + var forwardedAutoAssign = true; + final assignments = []; + + await tester.pumpWidget( + MaterialApp( + home: PromptWorkspaceDialog( + projects: [project], + agentProfiles: [profile], + loadBranches: (_) async => ['main'], + checkBranchExists: (_, _) async => false, + workspaceBranches: (_) => const {}, + parentWorkspaces: const [], + generateIdentity: + ({ + required operationId, + required projectId, + required prompt, + required autoAssignSection, + }) async { + forwardedAutoAssign = autoAssignSection; + return const GeneratedWorkspaceIdentity( + workspaceName: 'Prompt Workspace', + branchName: 'feat/prompt-workspace', + sectionId: 'section-1', + ); + }, + cancelGeneration: (_) async {}, + createWorkspace: + ({ + required project, + required sourceBranch, + required newBranchName, + required name, + parentWorkspaceId, + hostId, + issueUrl, + }) async { + return WorkspaceCreationResult( + workspace: _workspace( + id: 'workspace-1', + projectId: project.id, + name: name, + branch: newBranchName, + kind: .linked, + now: now, + ), + setupReport: .empty, + ); + }, + launchAgent: + ({ + required workspaceId, + required profileId, + required prompt, + required clientMutationId, + required requireIdempotency, + }) async => AgentProfileLaunchResult( + tabId: 'tab-1', + agentType: profile.agentType, + profileId: profileId, + idempotent: true, + ), + supportsIdempotentAgentLaunch: () async => true, + hasWorkspaceSections: true, + assignSection: (workspaceId, sectionId) async { + assignments.add('$workspaceId $sectionId'); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.text('Auto Assign Section')); + await tester.tap(find.text('Auto Assign Section')); + await tester.pump(); + await tester.enterText( + find.widgetWithText(TextField, 'Initial Prompt'), + 'Build workspace creation', + ); + final submit = find.text('Create And Start Agent'); + await tester.ensureVisible(submit); + await tester.tap(submit); + await tester.pumpAndSettle(); + + expect(forwardedAutoAssign, isFalse); + expect(assignments, isEmpty); + }); +} diff --git a/test/widget/prompt_workspace_dialog_clipboard_test_cases.dart b/test/widget/prompt_workspace_dialog_clipboard_test_cases.dart index 435087acb..1fd0b4468 100644 --- a/test/widget/prompt_workspace_dialog_clipboard_test_cases.dart +++ b/test/widget/prompt_workspace_dialog_clipboard_test_cases.dart @@ -25,6 +25,7 @@ void _registerPromptWorkspaceClipboardTests() { required operationId, required projectId, required prompt, + required autoAssignSection, }) async => const GeneratedWorkspaceIdentity( workspaceName: 'Prompt Workspace', branchName: 'feat/prompt-workspace', diff --git a/test/widget/prompt_workspace_dialog_linked_issue_test.dart b/test/widget/prompt_workspace_dialog_linked_issue_test.dart index 6a38e7e60..cc716e5c5 100644 --- a/test/widget/prompt_workspace_dialog_linked_issue_test.dart +++ b/test/widget/prompt_workspace_dialog_linked_issue_test.dart @@ -117,6 +117,7 @@ Future _pumpDialog( required operationId, required projectId, required prompt, + required autoAssignSection, }) async => throw UnimplementedError(), cancelGeneration: (_) async {}, createWorkspace: ({ diff --git a/test/widget/prompt_workspace_dialog_mode_test_cases.dart b/test/widget/prompt_workspace_dialog_mode_test_cases.dart index 96d577481..8ed2e7437 100644 --- a/test/widget/prompt_workspace_dialog_mode_test_cases.dart +++ b/test/widget/prompt_workspace_dialog_mode_test_cases.dart @@ -34,6 +34,7 @@ void _registerPromptWorkspaceModeTests() { required operationId, required projectId, required prompt, + required autoAssignSection, }) async => const GeneratedWorkspaceIdentity( workspaceName: 'Prompt Workspace', branchName: 'feat/prompt-workspace', diff --git a/test/widget/prompt_workspace_dialog_shortcut_test_cases.dart b/test/widget/prompt_workspace_dialog_shortcut_test_cases.dart index b37501e11..bee3a00e7 100644 --- a/test/widget/prompt_workspace_dialog_shortcut_test_cases.dart +++ b/test/widget/prompt_workspace_dialog_shortcut_test_cases.dart @@ -33,6 +33,7 @@ void _registerPromptWorkspaceShortcutTests() { required operationId, required projectId, required prompt, + required autoAssignSection, }) async => const GeneratedWorkspaceIdentity( workspaceName: 'Prompt Workspace', branchName: 'feat/prompt-workspace', diff --git a/test/widget/prompt_workspace_dialog_test.dart b/test/widget/prompt_workspace_dialog_test.dart index ea363b3c6..3a8b4acc2 100644 --- a/test/widget/prompt_workspace_dialog_test.dart +++ b/test/widget/prompt_workspace_dialog_test.dart @@ -19,12 +19,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; part 'prompt_workspace_dialog_clipboard_test_cases.dart'; +part 'prompt_workspace_dialog_auto_assign_test_cases.dart'; part 'prompt_workspace_dialog_mode_test_cases.dart'; part 'prompt_workspace_dialog_shortcut_test_cases.dart'; part 'prompt_workspace_dialog_test_support.dart'; void main() { _registerPromptWorkspaceClipboardTests(); + _registerPromptWorkspaceAutoAssignTests(); _registerPromptWorkspaceModeTests(); _registerPromptWorkspaceShortcutTests(); @@ -76,6 +78,7 @@ void main() { required operationId, required projectId, required prompt, + required autoAssignSection, }) async { generatedPrompt = prompt; return const GeneratedWorkspaceIdentity( @@ -216,6 +219,7 @@ void main() { required operationId, required projectId, required prompt, + required autoAssignSection, }) async => const GeneratedWorkspaceIdentity( workspaceName: 'Prompt Workspace', branchName: 'feat/prompt-workspace', @@ -380,6 +384,7 @@ void main() { required operationId, required projectId, required prompt, + required autoAssignSection, }) async => const GeneratedWorkspaceIdentity( workspaceName: 'Prompt Workspace', branchName: 'feat/prompt-workspace', diff --git a/test/widget/prompt_workspace_dialog_test_support.dart b/test/widget/prompt_workspace_dialog_test_support.dart index 3dcbf1fb8..2882ba2b8 100644 --- a/test/widget/prompt_workspace_dialog_test_support.dart +++ b/test/widget/prompt_workspace_dialog_test_support.dart @@ -67,6 +67,7 @@ Future _pumpEnqueuePromptDialog( required operationId, required projectId, required prompt, + required autoAssignSection, }) async => throw UnimplementedError(), cancelGeneration: (_) async {}, createWorkspace: ({ diff --git a/tool/quality/max_lines_baseline.txt b/tool/quality/max_lines_baseline.txt index fbaa2e17d..6acce5b61 100644 --- a/tool/quality/max_lines_baseline.txt +++ b/tool/quality/max_lines_baseline.txt @@ -83,3 +83,4 @@ 975 lib/src/features/workbench/presentation/workspace_git_diff_panel.dart 585 rust/alera-cli/src/cli_orchestration.rs 625 rust/alera-core/src/runtime/orchestration_task_store.rs +567 rust/alera-cli/src/cli_tests.rs