diff --git a/apps/backend/internal/mcp/handlers/config_task_handlers.go b/apps/backend/internal/mcp/handlers/config_task_handlers.go index bd56ae4a35..5b16f6cc66 100644 --- a/apps/backend/internal/mcp/handlers/config_task_handlers.go +++ b/apps/backend/internal/mcp/handlers/config_task_handlers.go @@ -253,10 +253,11 @@ func (h *Handlers) applyMoveTaskImmediate( return ws.NewError(msg.ID, msg.Action, classifyMoveTaskError(err), moveTaskErrorMessage(err), nil) } response := dto.MoveTaskResponse{ - Task: dto.FromTask(result.Task), - MoveID: result.MoveID, - EntryOptions: result.EntryOptions, - Disposition: moveDispositionApplied, + Task: dto.FromTask(result.Task), + WorkflowEntryIdentity: result.WorkflowEntryIdentity, + MoveID: result.MoveID, + EntryOptions: result.EntryOptions, + Disposition: moveDispositionApplied, } if result.WorkflowStep != nil { response.WorkflowStep = dto.FromWorkflowStep(result.WorkflowStep) diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index 870ca1d3f0..fedb6bfa52 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -3046,6 +3046,45 @@ func (s *Service) switchSessionForStepWithPoliciesAndCandidate( startPolicy models.WorkflowProfileSessionStartPolicy, endPolicy models.WorkflowProfileSessionEndPolicy, validatedExisting *models.TaskSession, +) (*models.TaskSession, error) { + return s.switchSessionForStepWithPoliciesAndCandidateAndRoute( + ctx, taskID, currentSession, newAgentProfileID, startPolicy, endPolicy, validatedExisting, nil, + ) +} + +func (s *Service) resolveWorkflowSessionSwitchExisting( + ctx context.Context, + taskID, newAgentProfileID string, + currentSession *models.TaskSession, + startPolicy models.WorkflowProfileSessionStartPolicy, + validatedExisting *models.TaskSession, +) *models.TaskSession { + if startPolicy != models.WorkflowProfileSessionStartPolicyReuse { + return nil + } + if validatedExisting != nil { + return validatedExisting + } + existing, err := s.findReusableSessionForProfile(ctx, taskID, newAgentProfileID, currentSession.ID) + if err != nil { + s.logger.Warn("failed to look up reusable session, falling through to create new", + zap.String("task_id", taskID), + zap.String("agent_profile_id", newAgentProfileID), + zap.Error(err)) + return nil + } + return existing +} + +func (s *Service) switchSessionForStepWithPoliciesAndCandidateAndRoute( + ctx context.Context, + taskID string, + currentSession *models.TaskSession, + newAgentProfileID string, + startPolicy models.WorkflowProfileSessionStartPolicy, + endPolicy models.WorkflowProfileSessionEndPolicy, + validatedExisting *models.TaskSession, + workflowRoute *models.WorkflowSessionRoute, ) (*models.TaskSession, error) { startPolicy = models.NormalizeWorkflowProfileSessionStartPolicy(string(startPolicy)) endPolicy = models.NormalizeWorkflowProfileSessionEndPolicy(string(endPolicy)) @@ -3056,21 +3095,9 @@ func (s *Service) switchSessionForStepWithPoliciesAndCandidate( zap.String("new_profile", newAgentProfileID), zap.String("profile_session_start_policy", string(startPolicy)), zap.String("profile_session_end_policy", string(endPolicy))) - var existing *models.TaskSession - if startPolicy == models.WorkflowProfileSessionStartPolicyReuse { - if validatedExisting != nil { - existing = validatedExisting - } else { - var lookupErr error - existing, lookupErr = s.findReusableSessionForProfile(ctx, taskID, newAgentProfileID, currentSession.ID) - if lookupErr != nil { - s.logger.Warn("failed to look up reusable session, falling through to create new", - zap.String("task_id", taskID), - zap.String("agent_profile_id", newAgentProfileID), - zap.Error(lookupErr)) - } - } - } + existing := s.resolveWorkflowSessionSwitchExisting( + ctx, taskID, newAgentProfileID, currentSession, startPolicy, validatedExisting, + ) targetSession := currentSession if existing != nil { targetSession = existing @@ -3101,7 +3128,19 @@ func (s *Service) switchSessionForStepWithPoliciesAndCandidate( } if existing != nil { - reused, err := s.reuseSessionForStepWithEndPolicy(ctx, taskID, currentSession, existing, endPolicy) + var reused *models.TaskSession + var err error + if workflowRoute != nil { + route := *workflowRoute + route.DestinationID = existing.ID + route.Phase = workflowSessionRoutePrepared + if err := s.persistWorkflowSessionRoute(ctx, taskID, route); err != nil { + return nil, err + } + reused, err = s.reuseSessionForStepWithEndPolicy(ctx, taskID, currentSession, existing, endPolicy, &route) + } else { + reused, err = s.reuseSessionForStepWithEndPolicy(ctx, taskID, currentSession, existing, endPolicy) + } if err == nil { return reused, nil } @@ -3114,7 +3153,9 @@ func (s *Service) switchSessionForStepWithPoliciesAndCandidate( zap.String("agent_profile_id", newAgentProfileID)) } - return s.createNewSessionForStepWithEndPolicy(ctx, taskID, currentSession, newAgentProfileID, endPolicy) + return s.createNewSessionForStepWithEndPolicyAndRoute( + ctx, taskID, currentSession, newAgentProfileID, endPolicy, workflowRoute, + ) } // findReusableSessionForProfile returns the most-recently-updated @@ -3807,6 +3848,14 @@ func (s *Service) prepareWorkflowStepSession( } effectiveProfile := s.resolveStepAgentProfile(ctx, step) startPolicy := s.resolveStepProfileSessionStartPolicy(step) + profileRoute := workflowProfileSessionRoute( + taskID, + session, + step, + effectiveProfile, + s.workflowEntryIdentity(ctx, taskID, entryIDs...), + startPolicy, + ) if shouldKeepCurrentWorkflowStepSession(effectiveProfile, session.AgentProfileID, startPolicy) { requiresFreshSession, err := s.workflowEntryRequiresFreshExactModelSession(ctx, session, step, sourceStep, effectiveProfile) if err != nil { @@ -3817,9 +3866,9 @@ func (s *Service) prepareWorkflowStepSession( return nil, false, fmt.Errorf("workflow profile switch source step is unavailable") } endPolicy := s.resolveStepProfileSessionEndPolicy(sourceStep) - return s.replaceExactModelWorkflowStepSession(ctx, taskID, session, step, effectiveProfile, endPolicy, entryIDs...) + return s.replaceExactModelWorkflowStepSession(ctx, taskID, session, step, effectiveProfile, endPolicy, profileRoute, entryIDs...) } - return s.keepCurrentWorkflowStepSession(ctx, taskID, session, step, entryIDs...) + return s.keepCurrentWorkflowStepSession(ctx, taskID, session, step, profileRoute, entryIDs...) } if sourceStep == nil { return nil, false, fmt.Errorf("workflow profile switch source step is unavailable") @@ -3829,7 +3878,7 @@ func (s *Service) prepareWorkflowStepSession( return nil, false, err } endPolicy := s.resolveStepProfileSessionEndPolicy(sourceStep) - newSession, err := s.switchSessionForStepWithPoliciesAndCandidate(ctx, taskID, session, effectiveProfile, startPolicy, endPolicy, validatedExisting) + newSession, err := s.switchSessionForStepWithPoliciesAndCandidateAndRoute(ctx, taskID, session, effectiveProfile, startPolicy, endPolicy, validatedExisting, profileRoute) if err != nil { return nil, false, err } @@ -3850,9 +3899,10 @@ func (s *Service) replaceExactModelWorkflowStepSession( step *wfmodels.WorkflowStep, profileID string, endPolicy models.WorkflowProfileSessionEndPolicy, + workflowRoute *models.WorkflowSessionRoute, entryIDs ...int64, ) (*models.TaskSession, bool, error) { - newSession, err := s.createNewSessionForStepWithEndPolicy(ctx, taskID, session, profileID, endPolicy) + newSession, err := s.createNewSessionForStepWithEndPolicyAndRoute(ctx, taskID, session, profileID, endPolicy, workflowRoute) if err != nil { return nil, false, err } @@ -3960,10 +4010,15 @@ func (s *Service) keepCurrentWorkflowStepSession( taskID string, session *models.TaskSession, step *wfmodels.WorkflowStep, + workflowRoute *models.WorkflowSessionRoute, entryIDs ...int64, ) (*models.TaskSession, bool, error) { s.tagSessionAsWorkflowSwitchedForSnapshot(ctx, session) - if !session.IsPrimary { + if workflowRoute != nil { + if err := s.promoteKeptWorkflowStepSession(ctx, taskID, session, workflowRoute); err != nil { + return nil, false, err + } + } else if !session.IsPrimary { if err := s.SetPrimarySession(ctx, session.ID); err != nil { s.logger.Warn("failed to preserve session as primary for workflow step", zap.String("task_id", taskID), @@ -3980,6 +4035,28 @@ func (s *Service) keepCurrentWorkflowStepSession( return session, false, nil } +func (s *Service) promoteKeptWorkflowStepSession( + ctx context.Context, + taskID string, + session *models.TaskSession, + workflowRoute *models.WorkflowSessionRoute, +) error { + preparedRoute := *workflowRoute + preparedRoute.DestinationID = session.ID + preparedRoute.Phase = workflowSessionRoutePrepared + if err := s.persistWorkflowSessionRoute(ctx, taskID, preparedRoute); err != nil { + return err + } + promoted, err := s.promoteWorkflowSessionRoute(ctx, taskID, session, &preparedRoute) + if err != nil { + return err + } + if !promoted { + return errReusableSessionNoLongerActive + } + return nil +} + func (s *Service) preflightWorkflowStepCredentials( ctx context.Context, taskID string, @@ -4360,8 +4437,10 @@ func (s *Service) launchAfterOnEnterDispatch( } s.logger.Info("implicit profile switch: reused session terminalized before dispatch, creating replacement", zap.String("task_id", taskID), zap.String("session_id", sessionID)) - replacement, replacementErr := s.createNewSessionForStep( + replacementRoute := s.workflowReplacementRoute(asyncCtx, taskID, step.ID, sessionID) + replacement, replacementErr := s.createNewSessionForStepWithEndPolicyAndRoute( asyncCtx, taskID, session, session.AgentProfileID, + models.WorkflowProfileSessionEndPolicyComplete, replacementRoute, ) if replacementErr != nil { s.logger.Error("failed to create replacement after terminalized profile switch", @@ -4399,8 +4478,10 @@ func (s *Service) launchAfterOnEnterDispatch( } s.logger.Info("implicit profile switch: reused session terminalized during dispatch, creating replacement", zap.String("task_id", taskID), zap.String("session_id", sessionID)) - replacement, replacementErr := s.createNewSessionForStep( + replacementRoute := s.workflowReplacementRoute(asyncCtx, taskID, step.ID, sessionID) + replacement, replacementErr := s.createNewSessionForStepWithEndPolicyAndRoute( asyncCtx, taskID, fresh, fresh.AgentProfileID, + models.WorkflowProfileSessionEndPolicyComplete, replacementRoute, ) if replacementErr != nil { s.logger.Error("failed to create replacement after terminalized dispatch", @@ -4473,8 +4554,10 @@ func (s *Service) replaceTerminalizedAutoStartSession( } s.logger.Info("creating fresh workflow session after reused session terminalized", zap.String("task_id", taskID), zap.String("session_id", sessionID)) - replacement, replacementErr := s.createNewSessionForStep( + replacementRoute := s.workflowReplacementRoute(ctx, taskID, step.ID, sessionID) + replacement, replacementErr := s.createNewSessionForStepWithEndPolicyAndRoute( ctx, taskID, session, session.AgentProfileID, + models.WorkflowProfileSessionEndPolicyComplete, replacementRoute, ) if replacementErr != nil { s.logger.Error("failed to create replacement after reused session terminalized", diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_profile_session_policy_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_profile_session_policy_test.go index 898a2e0ab1..db3ae1a755 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow_profile_session_policy_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_profile_session_policy_test.go @@ -43,6 +43,10 @@ func (r failProfileSwitchPromotionRepo) SetSessionPrimary(context.Context, strin return r.err } +func (r failProfileSwitchPromotionRepo) SetSessionPrimaryIfNonterminal(context.Context, string) (bool, error) { + return false, r.err +} + type profileSwitchFixture struct { repo *sqliterepo.Repository svc *Service diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_profile_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_profile_test.go index f21b116db2..7f28f46c9a 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow_profile_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_profile_test.go @@ -526,6 +526,17 @@ func TestPrepareWorkflowStepSession_PreservesMatchingProfileSession(t *testing.T if !updated.IsPrimary { t.Fatal("matching profile session must remain primary") } + task, err := repo.GetTask(ctx, "t1") + if err != nil { + t.Fatalf("reload task: %v", err) + } + route, ok := models.LoadWorkflowSessionRoute(task.Metadata) + if !ok { + t.Fatal("profile-only keep-current path must record a workflow session route") + } + if route.Phase != "committed" || route.DestinationID != session.ID || route.TargetKind != workflowSessionRouteTargetProfile { + t.Fatalf("profile-only keep-current route = %+v", route) + } } func TestPrepareWorkflowStepSession_ClearsCompletionFollowUpWithoutProfile(t *testing.T) { @@ -556,6 +567,14 @@ func TestPrepareWorkflowStepSession_ClearsCompletionFollowUpWithoutProfile(t *te if models.IsCompletionFollowUpSession(updated.Metadata) { t.Fatal("explicit workflow step entry retained completion follow-up ownership") } + task, err := repo.GetTask(ctx, "t1") + if err != nil { + t.Fatalf("reload task: %v", err) + } + route, ok := models.LoadWorkflowSessionRoute(task.Metadata) + if !ok || route.Phase != "committed" || route.DestinationID != session.ID { + t.Fatalf("profile-only default route = %+v, present=%t", route, ok) + } } func TestPrepareWorkflowStepSession_PreservesNewerCompletionFollowUpWithoutProfile(t *testing.T) { @@ -590,6 +609,48 @@ func TestPrepareWorkflowStepSession_PreservesNewerCompletionFollowUpWithoutProfi } } +func TestPrepareWorkflowStepSession_ProfileOnlyReuseCommitsEntryCorrelatedRecipient(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) + existing := &models.TaskSession{ + ID: "session-b-existing", TaskID: "t1", AgentProfileID: "profile-b", + ExecutorID: "exec-local", ExecutorProfileID: "ep1", TaskEnvironmentID: "env-1", + State: models.TaskSessionStateWaitingForInput, + StartedAt: time.Now().UTC().Add(-time.Minute), UpdatedAt: time.Now().UTC().Add(-time.Minute), + } + if err := fixture.repo.CreateTaskSession(ctx, existing); err != nil { + t.Fatalf("create reusable destination: %v", err) + } + target := &wfmodels.WorkflowStep{ + ID: "step-b", WorkflowID: "wf1", Position: 1, AgentProfileID: "profile-b", + ProfileSessionStartPolicy: models.WorkflowProfileSessionStartPolicyReuse, + } + source := &wfmodels.WorkflowStep{ + ID: "step-a", WorkflowID: "wf1", Position: 0, AgentProfileID: "profile-a", + ProfileSessionEndPolicy: models.WorkflowProfileSessionEndPolicyPark, + } + + selected, switched, err := fixture.svc.prepareWorkflowStepSession(ctx, "t1", fixture.current, target, source, 42) + if err != nil { + t.Fatalf("prepare profile-only reuse: %v", err) + } + if !switched || selected.ID != existing.ID { + t.Fatalf("selected profile-only reuse = %v, switched=%t", selected.ID, switched) + } + task, err := fixture.repo.GetTask(ctx, "t1") + if err != nil { + t.Fatalf("reload task: %v", err) + } + route, ok := models.LoadWorkflowSessionRoute(task.Metadata) + if !ok { + t.Fatal("profile-only reuse must record a workflow session route") + } + if route.Phase != workflowSessionRouteCommitted || route.DestinationID != existing.ID || + route.EntryIdentity != "entry:00000000000000000042" || route.DestinationStepID != target.ID { + t.Fatalf("profile-only reuse route = %+v", route) + } +} + func TestSwitchWorkflowDispatcherRoutesOnEnterToDestinationProfileSession(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) diff --git a/apps/backend/internal/orchestrator/workflow_session_target.go b/apps/backend/internal/orchestrator/workflow_session_target.go index af9425a8cd..49e23642c4 100644 --- a/apps/backend/internal/orchestrator/workflow_session_target.go +++ b/apps/backend/internal/orchestrator/workflow_session_target.go @@ -15,6 +15,10 @@ import ( const ( workflowSessionRoutePrepared = "prepared" workflowSessionRouteCommitted = "committed" + // workflowSessionRouteTargetProfile identifies the ordinary profile-only + // workflow path. It has no target step because the recipient is selected + // by the destination step's effective profile policy. + workflowSessionRouteTargetProfile = "profile" ) type workflowSessionBindingStore interface { @@ -254,14 +258,22 @@ func (s *Service) promoteWorkflowSessionRoute( promoteErr = err return } - destinationParkingStamp = s.captureWorkflowParkingStamp(admissionCtx, destination.ID) - if promoter, ok := s.repo.(workflowSessionRoutePromoter); ok && route != nil { + promoter, hasAtomicRoutePromotion := s.repo.(workflowSessionRoutePromoter) + if hasAtomicRoutePromotion { + destinationParkingStamp = s.captureWorkflowParkingStamp(admissionCtx, destination.ID) + } else { + destinationParkingStamp = workflowParkingStamp(destination) + } + if hasAtomicRoutePromotion && route != nil { committed := *route committed.DestinationID = destination.ID committed.Phase = workflowSessionRouteCommitted promoted, promoteErr = promoter.SetSessionPrimaryWithWorkflowSessionRouteIfNonterminal(admissionCtx, destination.ID, committed) return } + // Legacy repository adapters do not expose the atomic route promoter. + // Keep the nonterminal promotion guard and persist a route only after + // the selected destination has been promoted. promoted, promoteErr = s.setNonterminalSessionPrimary(admissionCtx, destination.ID) if promoteErr != nil || !promoted || route == nil { return @@ -282,10 +294,26 @@ func (s *Service) promoteWorkflowSessionRoute( // session. Clear the selected destination's current parking projection; // the stamped stop-intent tombstone remains for delayed callbacks. s.clearWorkflowParkingForExplicitExecution(ctx, destination.ID, destinationParkingStamp) - s.publishPrimarySessionUpdate(ctx, taskID, destination.ID) + if task, err := s.repo.GetTask(ctx, taskID); err == nil && task != nil { + s.publishTaskUpdated(ctx, task) + } else if err != nil { + s.logger.Warn("failed to fetch task after workflow session route promotion", + zap.String("task_id", taskID), zap.Error(err)) + } return true, nil } +func workflowParkingStamp(session *models.TaskSession) string { + if session == nil { + return "" + } + parking, ok := models.LoadWorkflowParking(session.Metadata) + if !ok { + return "" + } + return parking.Stamp +} + func (s *Service) clearWorkflowParkingForExplicitExecution( ctx context.Context, destinationID string, @@ -310,9 +338,15 @@ func (s *Service) clearWorkflowParkingForExplicitExecution( } func workflowSessionRouteMatchesStep(route *models.WorkflowSessionRoute, step *wfmodels.WorkflowStep, profileID string) bool { - if route == nil || step == nil || step.SessionTarget == nil { + if route == nil || step == nil { return false } + if step.SessionTarget == nil { + return route.DestinationStepID == step.ID && + route.TargetKind == workflowSessionRouteTargetProfile && + route.TargetStepID == "" && + route.AgentProfileID == profileID + } return route.DestinationStepID == step.ID && route.TargetKind == string(step.SessionTarget.Kind) && route.TargetStepID == step.SessionTarget.StepID && @@ -373,7 +407,7 @@ func (s *Service) reuseRecordedWorkflowSession( func workflowSessionRouteID(taskID, stepID, entryIdentity string, target *wfmodels.WorkflowSessionTarget, startPolicy models.WorkflowProfileSessionStartPolicy) string { targetID := "" - targetKind := "" + targetKind := workflowSessionRouteTargetProfile if target != nil { targetKind = string(target.Kind) targetID = target.StepID @@ -381,6 +415,47 @@ func workflowSessionRouteID(taskID, stepID, entryIdentity string, target *wfmode return fmt.Sprintf("workflow-session:%s:%s:%s:%s:%s:%s", taskID, stepID, entryIdentity, targetKind, targetID, startPolicy) } +func workflowProfileSessionRoute( + taskID string, + currentSession *models.TaskSession, + step *wfmodels.WorkflowStep, + profileID string, + entryIdentity string, + startPolicy models.WorkflowProfileSessionStartPolicy, +) *models.WorkflowSessionRoute { + if currentSession == nil || step == nil || step.SessionTarget != nil { + return nil + } + return &models.WorkflowSessionRoute{ + OperationID: workflowSessionRouteID(taskID, step.ID, entryIdentity, nil, startPolicy), + DestinationStepID: step.ID, + EntryIdentity: entryIdentity, + TargetKind: workflowSessionRouteTargetProfile, + AgentProfileID: profileID, + SourceSessionID: currentSession.ID, + Phase: workflowSessionRoutePrepared, + } +} + +func (s *Service) workflowReplacementRoute( + ctx context.Context, + taskID, stepID, terminalSessionID string, +) *models.WorkflowSessionRoute { + if s == nil || s.repo == nil || taskID == "" || stepID == "" || terminalSessionID == "" { + return nil + } + task, err := s.repo.GetTask(ctx, taskID) + if err != nil || task == nil { + return nil + } + route, ok := models.LoadWorkflowSessionRoute(task.Metadata) + if !ok || route.DestinationStepID != stepID || route.DestinationID != terminalSessionID { + return nil + } + route.Phase = workflowSessionRoutePrepared + return &route +} + // workflowEntryIdentity is the durable identity of one workflow-step entry. // Transition deliveries carry the immutable ledger id directly. Direct/manual // starts use the task's latest transition, while creation entries fall back to @@ -422,6 +497,18 @@ func (s *Service) reuseResolvedWorkflowSession( return nil, false, nil } if targetSession.ID == currentSession.ID { + baseRoute.DestinationID = currentSession.ID + baseRoute.Phase = workflowSessionRoutePrepared + if err := s.persistWorkflowSessionRoute(ctx, taskID, *baseRoute); err != nil { + return nil, false, err + } + promoted, err := s.promoteWorkflowSessionRoute(ctx, taskID, currentSession, baseRoute) + if err != nil { + return nil, false, err + } + if !promoted { + return nil, false, nil + } return currentSession, false, nil } diff --git a/apps/backend/internal/orchestrator/workflow_session_target_test.go b/apps/backend/internal/orchestrator/workflow_session_target_test.go index f14788cb7d..5e8e3bacbc 100644 --- a/apps/backend/internal/orchestrator/workflow_session_target_test.go +++ b/apps/backend/internal/orchestrator/workflow_session_target_test.go @@ -432,6 +432,42 @@ func TestWorkflowRouteRetryRecoversAfterLegacyCommitMetadataFailure(t *testing.T require.Equal(t, workflowSessionRouteCommitted, committed.Phase) } +func TestPromoteWorkflowSessionRouteUsesNonterminalGuardOnLegacyRepositories(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) + destination := &models.TaskSession{ + ID: "workflow-terminal-destination", + TaskID: "t1", + AgentProfileID: "profile-a", + State: models.TaskSessionStateCompleted, + } + require.NoError(t, fixture.repo.CreateTaskSession(ctx, destination)) + route := models.WorkflowSessionRoute{ + OperationID: "workflow-route-terminal-destination", + DestinationStepID: "step-review", + EntryIdentity: "entry:00000000000000000031", + TargetKind: string(wfmodels.WorkflowSessionTargetInitial), + AgentProfileID: "profile-a", + DestinationID: destination.ID, + Phase: workflowSessionRoutePrepared, + } + require.NoError(t, fixture.repo.SetTaskMetadataKey(ctx, "t1", models.MetaKeyWorkflowSessionRoute, route)) + + fixture.svc.repo = &failWorkflowRouteMetadataRepo{repoStore: fixture.repo} + promoted, err := fixture.svc.promoteWorkflowSessionRoute(ctx, "t1", destination, &route) + require.NoError(t, err) + require.False(t, promoted) + + updatedDestination, err := fixture.repo.GetTaskSession(ctx, destination.ID) + require.NoError(t, err) + require.False(t, updatedDestination.IsPrimary) + updatedTask, err := fixture.repo.GetTask(ctx, "t1") + require.NoError(t, err) + updatedRoute, ok := models.LoadWorkflowSessionRoute(updatedTask.Metadata) + require.True(t, ok) + require.Equal(t, workflowSessionRoutePrepared, updatedRoute.Phase) +} + func TestPromoteWorkflowSessionRouteClearsSelectedDestinationParkingOnly(t *testing.T) { ctx := context.Background() fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) @@ -492,6 +528,37 @@ func TestPromoteWorkflowSessionRouteClearsSelectedDestinationParkingOnly(t *test require.Equal(t, sourceParking.Stamp, parking.Stamp, "promotion cleared the source marker instead of destination") } +func TestReuseResolvedWorkflowSessionCommitsRouteForCurrentSession(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) + route := &models.WorkflowSessionRoute{ + OperationID: "workflow-route-current-session", + DestinationStepID: "step-review", + EntryIdentity: "entry:00000000000000000022", + TargetKind: string(wfmodels.WorkflowSessionTargetInitial), + AgentProfileID: fixture.current.AgentProfileID, + } + + reused, switched, err := fixture.svc.reuseResolvedWorkflowSession( + ctx, + "t1", + fixture.current, + fixture.current, + route, + models.WorkflowProfileSessionEndPolicyPark, + ) + require.NoError(t, err) + require.False(t, switched) + require.Equal(t, fixture.current.ID, reused.ID) + + task, err := fixture.repo.GetTask(ctx, "t1") + require.NoError(t, err) + committed, ok := models.LoadWorkflowSessionRoute(task.Metadata) + require.True(t, ok) + require.Equal(t, workflowSessionRouteCommitted, committed.Phase) + require.Equal(t, fixture.current.ID, committed.DestinationID) +} + func fixtureSessionState(t *testing.T, repo interface { GetTaskSession(context.Context, string) (*models.TaskSession, error) }, sessionID string) models.TaskSessionState { diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 1db50c34dd..29d1fcea99 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -1247,10 +1247,11 @@ type StepActionDTO struct { // MoveTaskResponse includes the task and the target workflow step info type MoveTaskResponse struct { - Task TaskDTO `json:"task"` - WorkflowStep WorkflowStepDTO `json:"workflow_step"` - MoveID string `json:"move_id,omitempty"` - EntryOptions *workflowmove.EntryOptions `json:"entry_options,omitempty"` + Task TaskDTO `json:"task"` + WorkflowStep WorkflowStepDTO `json:"workflow_step"` + WorkflowEntryIdentity string `json:"workflow_entry_identity,omitempty"` + MoveID string `json:"move_id,omitempty"` + EntryOptions *workflowmove.EntryOptions `json:"entry_options,omitempty"` // Disposition reports how an MCP move_task call was resolved: "applied" when // the move committed immediately, or "deferred" when it was recorded to run // at the source session's turn-end. It lets an agent distinguish deferred diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index a1b8e26544..f11a43fc8b 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -1838,9 +1838,10 @@ func (h *TaskHandlers) httpMoveTask(c *gin.Context) { } response := dto.MoveTaskResponse{ - Task: dto.FromTask(result.Task), - MoveID: result.MoveID, - EntryOptions: result.EntryOptions, + Task: dto.FromTask(result.Task), + WorkflowEntryIdentity: result.WorkflowEntryIdentity, + MoveID: result.MoveID, + EntryOptions: result.EntryOptions, } if result.WorkflowStep != nil { response.WorkflowStep = dto.FromWorkflowStep(result.WorkflowStep) diff --git a/apps/backend/internal/task/handlers/task_ws_handlers.go b/apps/backend/internal/task/handlers/task_ws_handlers.go index 4c52be2c34..022abcebb0 100644 --- a/apps/backend/internal/task/handlers/task_ws_handlers.go +++ b/apps/backend/internal/task/handlers/task_ws_handlers.go @@ -499,9 +499,10 @@ func (h *TaskHandlers) wsMoveTask(ctx context.Context, msg *ws.Message) (*ws.Mes } response := dto.MoveTaskResponse{ - Task: dto.FromTask(result.Task), - MoveID: result.MoveID, - EntryOptions: result.EntryOptions, + Task: dto.FromTask(result.Task), + WorkflowEntryIdentity: result.WorkflowEntryIdentity, + MoveID: result.MoveID, + EntryOptions: result.EntryOptions, } if result.WorkflowStep != nil { response.WorkflowStep = dto.FromWorkflowStep(result.WorkflowStep) diff --git a/apps/backend/internal/task/service/service_workflow.go b/apps/backend/internal/task/service/service_workflow.go index 44a20254f4..a7ec988588 100644 --- a/apps/backend/internal/task/service/service_workflow.go +++ b/apps/backend/internal/task/service/service_workflow.go @@ -420,6 +420,9 @@ type MoveTaskResult struct { // this call's earlier pre-move snapshot — see Task.FromStepID's doc. FromStepID string Transitioned bool + // WorkflowEntryIdentity identifies the committed workflow-step entry that + // accepted this move. Empty when the write did not transition the task. + WorkflowEntryIdentity string // MoveID correlates the one-shot entry options carried on the transient // move marker with the target-step entry. Empty for an option-less move. MoveID string @@ -732,6 +735,10 @@ func (s *Service) MoveTaskWithOptions( resultFromWorkflowID := task.FromWorkflowID resultFromStepID := task.FromStepID resultTransitioned := task.WorkflowStepTransitionID != 0 + workflowEntryIdentity := "" + if resultTransitioned && task.WorkflowStepTransitionID > 0 { + workflowEntryIdentity = fmt.Sprintf("entry:%020d", task.WorkflowStepTransitionID) + } if resultTransitioned && resultFromWorkflowID == "" { // Keep compatibility with repository implementations that predate the // transient source-workflow field. SQLite populates it from the write @@ -797,7 +804,14 @@ func (s *Service) MoveTaskWithOptions( zap.String("workflow_step_id", workflowStepID), zap.Int("position", position)) - result := &MoveTaskResult{Task: task, FromStepID: resultFromStepID, Transitioned: resultTransitioned, MoveID: moveID, EntryOptions: entryOptions} + result := &MoveTaskResult{ + Task: task, + FromStepID: resultFromStepID, + Transitioned: resultTransitioned, + WorkflowEntryIdentity: workflowEntryIdentity, + MoveID: moveID, + EntryOptions: entryOptions, + } // Fetch the workflow step info if getter is available if s.workflowStepGetter != nil { diff --git a/apps/backend/internal/task/service/service_workflow_focus_test.go b/apps/backend/internal/task/service/service_workflow_focus_test.go new file mode 100644 index 0000000000..fc9b76fac8 --- /dev/null +++ b/apps/backend/internal/task/service/service_workflow_focus_test.go @@ -0,0 +1,47 @@ +package service + +import ( + "context" + "regexp" + "testing" +) + +var workflowEntryIdentityPattern = regexp.MustCompile(`^entry:[0-9]{20}$`) + +func TestService_MoveTaskWorkflowFocusReturnsCommittedEntryIdentity(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + seedMoveWorkflows(t, ctx, repo) + seedMoveSteps(svc) + createMoveTask(t, ctx, repo, "task-focus", "wf-source", "step-source", nil) + + result, err := svc.MoveTask(ctx, "task-focus", "wf-source", "step-review-target", 0) + if err != nil { + t.Fatalf("MoveTask: %v", err) + } + if !workflowEntryIdentityPattern.MatchString(result.WorkflowEntryIdentity) { + t.Fatalf("workflow entry identity = %q, want entry:<20-digit transition id>", result.WorkflowEntryIdentity) + } + if result.MoveID != "" { + t.Fatalf("ordinary move unexpectedly returned move id %q", result.MoveID) + } +} + +func TestService_MoveTaskWorkflowFocusOmitsIdentityForNoOpMove(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + seedMoveWorkflows(t, ctx, repo) + seedMoveSteps(svc) + createMoveTask(t, ctx, repo, "task-focus-noop", "wf-source", "step-source", nil) + + result, err := svc.MoveTask(ctx, "task-focus-noop", "wf-source", "step-source", 0) + if err != nil { + t.Fatalf("MoveTask: %v", err) + } + if result.Transitioned { + t.Fatal("same-step move reported a transition") + } + if result.WorkflowEntryIdentity != "" { + t.Fatalf("same-step move returned workflow entry identity %q", result.WorkflowEntryIdentity) + } +} diff --git a/apps/web/components/kanban-with-preview.tsx b/apps/web/components/kanban-with-preview.tsx index 8a32eecd89..23c9092018 100644 --- a/apps/web/components/kanban-with-preview.tsx +++ b/apps/web/components/kanban-with-preview.tsx @@ -1,5 +1,7 @@ "use client"; +/* eslint-disable max-lines -- board preview owns the responsive board and desktop preview surface. */ + import { useCallback, useEffect, @@ -311,18 +313,109 @@ function useSyncSelectedTaskActivity(params: { selectedTaskId: string | null | undefined; activeSessionId: string | null; setActiveSession: (taskId: string, sessionId: string) => void; + setActiveSessionAuto: (taskId: string, sessionId: string) => void; + workflowFocusRequestActive: boolean; setActiveTask: (taskId: string) => void; }) { - const { isOpen, selectedTaskId, activeSessionId, setActiveSession, setActiveTask } = params; + const { + isOpen, + selectedTaskId, + activeSessionId, + setActiveSession, + setActiveSessionAuto, + workflowFocusRequestActive, + setActiveTask, + } = params; useEffect(() => { if (!isOpen || !selectedTaskId) return; if (activeSessionId) { - setActiveSession(selectedTaskId, activeSessionId); + if (workflowFocusRequestActive) { + setActiveSessionAuto(selectedTaskId, activeSessionId); + } else { + setActiveSession(selectedTaskId, activeSessionId); + } } else { setActiveTask(selectedTaskId); } - }, [activeSessionId, isOpen, selectedTaskId, setActiveSession, setActiveTask]); + }, [ + activeSessionId, + isOpen, + selectedTaskId, + setActiveSession, + setActiveSessionAuto, + workflowFocusRequestActive, + setActiveTask, + ]); +} + +function usePreviewSessionFocus({ + previewIsOpen, + previewTaskId, + selectedTask, + selectedTaskSessionId, + userSelectedSessionId, + setUserSelectedSessionId, +}: { + previewIsOpen: boolean; + previewTaskId: string | null | undefined; + selectedTask: Task | null; + selectedTaskSessionId: string | null; + userSelectedSessionId: string | null; + setUserSelectedSessionId: (sessionId: string | null) => void; +}) { + const setActiveTask = useAppStore((state) => state.setActiveTask); + const setActiveSession = useAppStore((state) => state.setActiveSession); + const setActiveSessionAuto = useAppStore((state) => state.setActiveSessionAuto); + const workflowFocusRequest = useAppStore((state) => state.workflowSessionFocus.request); + const acknowledgeWorkflowSessionFocus = useAppStore( + (state) => state.acknowledgeWorkflowSessionFocus, + ); + let activeSessionId: string | null = null; + if (previewTaskId) { + if (workflowFocusRequest?.taskId === previewTaskId) { + activeSessionId = workflowFocusRequest.sessionId; + } else { + activeSessionId = + userSelectedSessionId ?? selectedTask?.primarySessionId ?? selectedTaskSessionId; + } + } + + const handleSessionChange = useCallback( + (sessionId: string | null) => { + setUserSelectedSessionId(sessionId); + if (previewTaskId && sessionId) setActiveSession(previewTaskId, sessionId); + }, + [previewTaskId, setActiveSession, setUserSelectedSessionId], + ); + + useEffect(() => { + if (!previewIsOpen || !previewTaskId || workflowFocusRequest?.taskId !== previewTaskId) { + return; + } + setUserSelectedSessionId(null); + setActiveSessionAuto(previewTaskId, workflowFocusRequest.sessionId); + acknowledgeWorkflowSessionFocus(workflowFocusRequest.requestId); + }, [ + acknowledgeWorkflowSessionFocus, + previewIsOpen, + previewTaskId, + setActiveSessionAuto, + setUserSelectedSessionId, + workflowFocusRequest, + ]); + + useSyncSelectedTaskActivity({ + isOpen: previewIsOpen, + selectedTaskId: previewTaskId, + activeSessionId, + setActiveSession, + setActiveSessionAuto, + workflowFocusRequestActive: workflowFocusRequest?.taskId === previewTaskId, + setActiveTask, + }); + + return { activeSessionId, handleSessionChange }; } export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWithPreviewProps) { @@ -334,8 +427,6 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit const kanbanWorkflowId = useAppStore((state) => state.kanban.workflowId); const kanbanIsLoading = useAppStore((state) => state.kanban.isLoading ?? false); const kanbanMultiSnapshots = useAppStore((state) => state.kanbanMulti.snapshots); - const setActiveTask = useAppStore((state) => state.setActiveTask); - const setActiveSession = useAppStore((state) => state.setActiveSession); const setKanbanPreviewedTaskId = useAppStore((state) => state.setKanbanPreviewedTaskId); const hasLoadedTaskSources = hasLoadedKanbanTaskSources({ activeWorkflowId: kanbanWorkflowId, @@ -403,16 +494,13 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit [router], ); - const activeSessionId = previewTaskId - ? (userSelectedSessionId ?? selectedTask?.primarySessionId ?? selectedTaskSessionId) - : null; - - useSyncSelectedTaskActivity({ - isOpen: previewIsOpen, - selectedTaskId: previewTaskId, - activeSessionId, - setActiveSession, - setActiveTask, + const { activeSessionId, handleSessionChange } = usePreviewSessionFocus({ + previewIsOpen, + previewTaskId, + selectedTask, + selectedTaskSessionId, + userSelectedSessionId, + setUserSelectedSessionId, }); useUrlSync(previewTaskId ?? null, previewIsOpen ? (activeSessionId ?? null) : null); @@ -446,7 +534,7 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit onPreviewTask={handlePreviewTaskWithData} onNavigateToTask={handleNavigateToTask} onClose={close} - onSessionChange={setUserSelectedSessionId} + onSessionChange={handleSessionChange} onResizeMouseDown={handleResizeMouseDown} onActionsMenuOpenChange={setActionsMenuOpen} /> diff --git a/apps/web/components/task/dockview-session-tab-activation.test.ts b/apps/web/components/task/dockview-session-tab-activation.test.ts index f8d1d64ea7..144cae9c7e 100644 --- a/apps/web/components/task/dockview-session-tab-activation.test.ts +++ b/apps/web/components/task/dockview-session-tab-activation.test.ts @@ -2,6 +2,20 @@ import { describe, expect, it } from "vitest"; import { shouldActivateSessionPanel } from "./dockview-session-tab-activation"; describe("shouldActivateSessionPanel", () => { + it("activates a committed manual workflow recipient even when another panel is selected", () => { + expect( + shouldActivateSessionPanel({ + sessionPanelExistedBefore: true, + prevTaskId: "task-1", + prevSessionId: "session-astra", + currentTaskId: "task-1", + currentSessionId: "session-luna", + currentActivePanelId: "plan", + workflowFocusRequested: true, + }), + ).toBe(true); + }); + const sessionId = "s-current"; const baseArgs = { prevTaskId: null, diff --git a/apps/web/components/task/dockview-session-tab-activation.ts b/apps/web/components/task/dockview-session-tab-activation.ts index 2ff8adebfe..5105b1637d 100644 --- a/apps/web/components/task/dockview-session-tab-activation.ts +++ b/apps/web/components/task/dockview-session-tab-activation.ts @@ -7,6 +7,7 @@ export type SessionPanelActivationArgs = { currentTaskId: string | null; currentSessionId: string; currentActivePanelId: string | null; + workflowFocusRequested?: boolean; }; export function shouldPreserveActivePanel( @@ -47,8 +48,10 @@ export function shouldActivateSessionPanel(args: SessionPanelActivationArgs): bo currentTaskId, currentSessionId, currentActivePanelId, + workflowFocusRequested = false, } = args; const sessionPanelId = `session:${currentSessionId}`; + if (workflowFocusRequested) return true; if (!sessionPanelExistedBefore) { return ( !currentActivePanelId || diff --git a/apps/web/components/task/dockview-session-tabs.hook.test.tsx b/apps/web/components/task/dockview-session-tabs.hook.test.tsx index a956997216..a52625ec49 100644 --- a/apps/web/components/task/dockview-session-tabs.hook.test.tsx +++ b/apps/web/components/task/dockview-session-tabs.hook.test.tsx @@ -1,8 +1,10 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, render } from "@testing-library/react"; import type { DockviewApi } from "dockview-react"; -import { StateProvider } from "@/components/state-provider"; +import type { StoreApi } from "zustand"; +import { StateProvider, useAppStoreApi } from "@/components/state-provider"; import { defaultState } from "@/lib/state/default-state"; +import type { AppState } from "@/lib/state/store"; import { useDockviewStore } from "@/lib/state/dockview-store"; import type { TaskSession, TaskId } from "@/lib/types/http"; import { makeReorderingAutoSessionApi } from "./dockview-session-tabs.test-utils"; @@ -17,6 +19,14 @@ function Harness() { return null; } +let capturedStore: StoreApi | null = null; + +function FocusHarness() { + capturedStore = useAppStoreApi(); + useAutoSessionTab(ACTIVE_SESSION_ID); + return null; +} + function renderHookWithHydratedSessions() { const sessions = [ACTIVE_SESSION_ID, SIBLING_SESSION_ID].map((id) => ({ id }) as TaskSession); @@ -60,4 +70,90 @@ describe("useAutoSessionTab", () => { expect.arrayContaining([`session:${ACTIVE_SESSION_ID}`, `session:${SIBLING_SESSION_ID}`]), ); }); + + it("subscribes to a same-session focus request and acknowledges after activation", () => { + const { api, activationSequence, centerActivePanelId } = makeReorderingAutoSessionApi("files"); + const route = { + operation_id: "workflow-session:task-1:step-b:entry:00000000000000000042:profile::reuse", + destination_step_id: "step-b", + entry_identity: "entry:00000000000000000042", + destination_session_id: ACTIVE_SESSION_ID, + phase: "committed", + }; + capturedStore = null; + + render( + + + , + ); + + expect(capturedStore).not.toBeNull(); + const store = capturedStore as unknown as StoreApi; + act(() => { + useDockviewStore.setState({ api: api as DockviewApi }); + }); + activationSequence.length = 0; + + const acknowledge = store.getState().acknowledgeWorkflowSessionFocus; + const acknowledgeSpy = vi.fn((requestId: number) => acknowledge(requestId)); + store.setState({ acknowledgeWorkflowSessionFocus: acknowledgeSpy }); + + act(() => { + const requestId = store.getState().beginWorkflowSessionFocus({ + taskId: TASK_ID, + workflowId: "workflow-1", + destinationStepId: "step-b", + presentationToken: 1, + navigationRevision: 0, + }); + expect(requestId).toBe(1); + store.getState().bindWorkflowSessionFocus({ + requestId: requestId!, + presentationToken: 1, + entryIdentity: route.entry_identity, + }); + store.getState().reconcileWorkflowSessionFocus(TASK_ID); + }); + + expect(centerActivePanelId()).toBe(`session:${ACTIVE_SESSION_ID}`); + expect(acknowledgeSpy).toHaveBeenCalledTimes(1); + expect(acknowledgeSpy).toHaveBeenCalledWith(1); + expect(activationSequence.filter((id) => id === `session:${ACTIVE_SESSION_ID}`)).toHaveLength( + 1, + ); + expect(store.getState().workflowSessionFocus.request).toBeNull(); + }); }); diff --git a/apps/web/components/task/dockview-session-tabs.ts b/apps/web/components/task/dockview-session-tabs.ts index 8a4df1617b..c5b305c2ef 100644 --- a/apps/web/components/task/dockview-session-tabs.ts +++ b/apps/web/components/task/dockview-session-tabs.ts @@ -297,16 +297,26 @@ type AutoSessionTabRefs = { * Activate the newly-ensured session panel and update the center-group store * entry. Returns the resolved active panel for sibling anchoring. */ -function activateSessionPanel( - api: DockviewApi, - effectiveSessionId: string, +function activateSessionPanel({ + api, + effectiveSessionId, + activation, + refs, + tid, + appStore, + workflowFocusRequestId, +}: { + api: DockviewApi; + effectiveSessionId: string; activation: { sessionPanelExistedBefore: boolean; activePanelIdBeforeEnsure: string | null; - }, - refs: AutoSessionTabRefs, - tid: string | null, -): ReturnType { + }; + refs: AutoSessionTabRefs; + tid: string | null; + appStore: ReturnType; + workflowFocusRequestId: number | null; +}): ReturnType { const activePanel = api.getPanel(`session:${effectiveSessionId}`); if (!activePanel) return activePanel; @@ -318,6 +328,7 @@ function activateSessionPanel( currentTaskId: tid, currentSessionId: effectiveSessionId, currentActivePanelId: activation.activePanelIdBeforeEnsure, + workflowFocusRequested: workflowFocusRequestId !== null, }); if (isDebug()) { debug("useAutoSessionTab: activation decision", { @@ -332,7 +343,12 @@ function activateSessionPanel( activeGroupId: activePanel.group.id, }); } - if (shouldActivate) activePanel.api.setActive(); + if (shouldActivate) { + activePanel.api.setActive(); + if (workflowFocusRequestId !== null) { + appStore.getState().acknowledgeWorkflowSessionFocus(workflowFocusRequestId); + } + } useDockviewStore.setState({ centerGroupId: isCenterCandidateGroupId(activePanel.group.id) ? activePanel.group.id @@ -484,6 +500,15 @@ function updateAutoSessionTabRefs( refs.prevSessionIdRef.current = effectiveSessionId; } +function workflowFocusRequestIdForSession( + appStore: ReturnType, + taskId: string | null, + sessionId: string, +): number | null { + const request = appStore.getState().workflowSessionFocus?.request; + return request?.taskId === taskId && request.sessionId === sessionId ? request.requestId : null; +} + /** * Core effect body for useAutoSessionTab — extracted to reduce complexity of * the hook itself. @@ -533,16 +558,20 @@ export function runAutoSessionTabEffect( } const chatWasSelected = isChatPlaceholderSelected(api); + const workflowFocusRequestId = workflowFocusRequestIdForSession( + appStore, + tid, + effectiveSessionId, + ); const initialPosition = resolveInitialPosition(api, chatWasSelected); const sessionPanelExistedBefore = !!api.getPanel(`session:${effectiveSessionId}`); // Preserve the restored/user-selected panel before adding and reordering the // session tab. Dockview's same-group move briefly activates a sibling even // with skipSetActive, so reading api.activePanel afterward loses this intent. const activePanelIdBeforeEnsure = api.activePanel?.id ?? null; - const preserveActivePanel = shouldPreserveActivePanel( - sessionPanelExistedBefore, - activePanelIdBeforeEnsure, - ); + const preserveActivePanel = + workflowFocusRequestId === null && + shouldPreserveActivePanel(sessionPanelExistedBefore, activePanelIdBeforeEnsure); logSessionPanelEnsure(effectiveSessionId, sessionPanelExistedBefore, initialPosition); @@ -567,13 +596,15 @@ export function runAutoSessionTabEffect( removeChatPlaceholder(api); ensureSessionTabPrecedesNonSessionTabs(api, effectiveSessionId); - const activePanel = activateSessionPanel( + const activePanel = activateSessionPanel({ api, effectiveSessionId, - { sessionPanelExistedBefore, activePanelIdBeforeEnsure }, + activation: { sessionPanelExistedBefore, activePanelIdBeforeEnsure }, refs, tid, - ); + appStore, + workflowFocusRequestId, + }); restorePreservedActivePanel(api, activePanelIdBeforeEnsure, preserveActivePanel); @@ -623,6 +654,12 @@ export function useAutoSessionTab(effectiveSessionId: string | null) { if (!list || list.length === 0) return EMPTY_SESSION_IDS_KEY; return list.map((ss) => ss.id).join(","); }); + const workflowFocusRequestId = useAppStore((s) => { + const request = s.workflowSessionFocus.request; + return request?.taskId === s.tasks.activeTaskId && request.sessionId === effectiveSessionId + ? request.requestId + : null; + }); useEffect(() => { runAutoSessionTabEffect(effectiveSessionId, appStore, { @@ -630,5 +667,5 @@ export function useAutoSessionTab(effectiveSessionId: string | null) { prevTaskIdRef, prevSessionIdRef, }); - }, [appStore, dockviewApi, effectiveSessionId, sessionIdsKey]); + }, [appStore, dockviewApi, effectiveSessionId, sessionIdsKey, workflowFocusRequestId]); } diff --git a/apps/web/components/task/mobile/mobile-task-move-options.tsx b/apps/web/components/task/mobile/mobile-task-move-options.tsx index 8ff89d0aea..3538cc7c43 100644 --- a/apps/web/components/task/mobile/mobile-task-move-options.tsx +++ b/apps/web/components/task/mobile/mobile-task-move-options.tsx @@ -1,13 +1,19 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { IconArrowLeft } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { DrawerHeader, DrawerTitle } from "@kandev/ui/drawer"; import { SheetHeader, SheetTitle } from "@kandev/ui/sheet"; +import { useAppStore } from "@/components/state-provider"; +import { usePresentationToken } from "@/hooks/domains/kanban/use-workflow-step-move"; +import { useWorkflowMove } from "@/hooks/domains/kanban/use-workflow-move"; +import type { WorkflowMoveResponse } from "@/lib/api/domains/kanban-api"; import { useTaskMoveOptions, type TaskMoveStep } from "../task-move-context-menu"; import { WorkflowMoveOptionsForm, type WorkflowMoveOptionsSubmit } from "../workflow-move-options"; +import { useToast } from "@/components/toast-provider"; +import { getTaskMoveErrorMessage } from "../task-move-error-message"; export type MobileMoveOptionsRequest = { taskId: string; @@ -15,18 +21,172 @@ export type MobileMoveOptionsRequest = { targetStep: TaskMoveStep; }; +type MobileFocusRequest = { + requestId: number; + taskId: string; + presentationToken: number; +}; + +function useMobileWorkflowSessionFocus(activeTaskId: string | null) { + const presentationToken = usePresentationToken(activeTaskId); + const navigationRevision = useAppStore((state) => state.taskRemoval.navigationRevision); + const beginWorkflowSessionFocus = useAppStore((state) => state.beginWorkflowSessionFocus); + const bindWorkflowSessionFocus = useAppStore((state) => state.bindWorkflowSessionFocus); + const reconcileWorkflowSessionFocus = useAppStore((state) => state.reconcileWorkflowSessionFocus); + const cancelWorkflowSessionFocus = useAppStore((state) => state.cancelWorkflowSessionFocus); + const focusRequestRef = useRef(null); + + const beginFocus = useCallback( + (taskId: string, workflowId: string, destinationStepId: string) => { + if (taskId !== activeTaskId) return null; + const requestId = beginWorkflowSessionFocus({ + taskId, + workflowId, + destinationStepId, + presentationToken, + navigationRevision, + }); + if (requestId === null) return null; + focusRequestRef.current = { requestId, taskId, presentationToken }; + return requestId; + }, + [activeTaskId, beginWorkflowSessionFocus, navigationRevision, presentationToken], + ); + const cancelFocus = useCallback( + (requestId: number) => { + const focusRequest = focusRequestRef.current; + if (!focusRequest || focusRequest.requestId !== requestId) return; + cancelWorkflowSessionFocus({ + requestId: focusRequest.requestId, + presentationToken: focusRequest.presentationToken, + }); + focusRequestRef.current = null; + }, + [cancelWorkflowSessionFocus], + ); + const clearFocus = useCallback((requestId: number) => { + if (focusRequestRef.current?.requestId === requestId) focusRequestRef.current = null; + }, []); + const cancelCurrentFocus = useCallback(() => { + const focusRequest = focusRequestRef.current; + if (!focusRequest) return; + cancelWorkflowSessionFocus({ + requestId: focusRequest.requestId, + presentationToken: focusRequest.presentationToken, + }); + focusRequestRef.current = null; + }, [cancelWorkflowSessionFocus]); + const handleMoveCommitted = useCallback( + (response: WorkflowMoveResponse, expectedRequestId?: number) => { + const focusRequest = focusRequestRef.current; + if (!focusRequest) return; + if (expectedRequestId !== undefined && focusRequest.requestId !== expectedRequestId) return; + if (!response.workflow_entry_identity) { + cancelFocus(focusRequest.requestId); + return; + } + bindWorkflowSessionFocus({ + requestId: focusRequest.requestId, + presentationToken: focusRequest.presentationToken, + entryIdentity: response.workflow_entry_identity, + }); + reconcileWorkflowSessionFocus(focusRequest.taskId, { + metadata: response.task?.metadata, + updatedAt: response.task?.updated_at, + workflowStepId: response.task?.workflow_step_id, + entryIdentity: response.workflow_entry_identity, + }); + }, + [bindWorkflowSessionFocus, cancelFocus, reconcileWorkflowSessionFocus], + ); + + useEffect( + () => () => { + const focusRequest = focusRequestRef.current; + if (!focusRequest) return; + cancelWorkflowSessionFocus({ + requestId: focusRequest.requestId, + presentationToken: focusRequest.presentationToken, + }); + }, + [cancelWorkflowSessionFocus], + ); + + return { + beginFocus, + cancelFocus, + cancelCurrentFocus, + clearFocus, + handleMoveCommitted, + navigationRevision, + presentationToken, + }; +} + +function useMobileDirectTaskMove({ + stepsByWorkflowId, + focus, +}: { + stepsByWorkflowId: Record; + focus: ReturnType; +}) { + const { move, isMoving } = useWorkflowMove(); + const { toast } = useToast(); + const { t } = useTranslation("task"); + const handleMove = useCallback( + (taskId: string, workflowId: string, targetStepId: string) => { + const targetStep = stepsByWorkflowId[workflowId]?.find((step) => step.id === targetStepId); + if (!targetStep) return; + const focusRequestId = focus.beginFocus(taskId, workflowId, targetStepId); + void move(taskId, { + workflow_id: workflowId, + workflow_step_id: targetStepId, + position: 0, + }).then((result) => { + if (result.disposition === "failed") { + if (focusRequestId !== null) focus.cancelFocus(focusRequestId); + const fallback = t("taskMoveErrorGeneric"); + toast({ + title: t("failedToMoveTask"), + description: getTaskMoveErrorMessage(result.error, fallback, t), + variant: "error", + }); + return; + } + if (focusRequestId !== null) { + focus.handleMoveCommitted(result.response, focusRequestId); + focus.clearFocus(focusRequestId); + } + toast({ + title: t("movedTasksToStep", { count: 1 }), + description: t("movedTasksStepDescription", { count: 1 }), + variant: "success", + }); + }); + }, + [focus, move, stepsByWorkflowId, t, toast], + ); + + return { handleMove, isMoving }; +} + export function useMobileTaskMoveOptions({ open, + activeTaskId, stepsByWorkflowId, }: { open: boolean; + activeTaskId: string | null; stepsByWorkflowId: Record; }) { const [request, setRequest] = useState(null); + const focus = useMobileWorkflowSessionFocus(activeTaskId); + const directMove = useMobileDirectTaskMove({ stepsByWorkflowId, focus }); const moveOptions = useTaskMoveOptions({ taskId: request?.taskId ?? "", workflowId: request?.workflowId, steps: request ? [request.targetStep] : undefined, + onMoveCommitted: focus.handleMoveCommitted, }); const handleRequest = useCallback( (taskId: string, workflowId: string, targetStepId: string) => { @@ -44,24 +204,31 @@ export function useMobileTaskMoveOptions({ }, [moveOptions.closeMoveOptions]); const handleSubmit = useCallback( async (entryOptions: Parameters[0]) => { + const focusRequestId = request?.workflowId + ? focus.beginFocus(request.taskId, request.workflowId, request.targetStep.id) + : null; const moved = await moveOptions.submitMoveOptions(entryOptions); + if (!moved && focusRequestId !== null) focus.cancelFocus(focusRequestId); + if (moved && focusRequestId !== null) focus.clearFocus(focusRequestId); if (moved) setRequest(null); return moved; }, - [moveOptions.submitMoveOptions], + [focus, moveOptions.submitMoveOptions, request], ); useEffect(() => { if (open || !request) return; moveOptions.closeMoveOptions(); setRequest(null); - }, [moveOptions.closeMoveOptions, open, request]); + focus.cancelCurrentFocus(); + }, [focus.cancelCurrentFocus, moveOptions.closeMoveOptions, open, request]); return { request, moveOptionsStep: moveOptions.moveOptionsStep, - isMoving: moveOptions.isMoving, + isMoving: moveOptions.isMoving || directMove.isMoving, handleRequest, + handleMove: directMove.handleMove, handleClose, handleSubmit, }; diff --git a/apps/web/components/task/mobile/session-mobile-layout.tsx b/apps/web/components/task/mobile/session-mobile-layout.tsx index 6b224017bc..914941d915 100644 --- a/apps/web/components/task/mobile/session-mobile-layout.tsx +++ b/apps/web/components/task/mobile/session-mobile-layout.tsx @@ -664,6 +664,20 @@ export const SessionMobileLayout = memo(function SessionMobileLayout( handleOpenFile, handlePanelChangeAndClearSheet, } = useMobilePanelHandlers({ effectiveSessionId, handlePanelChange }); + const workflowFocusRequest = useAppStore((state) => { + const request = state.workflowSessionFocus.request; + return request && request.taskId === activeTaskId && request.sessionId === effectiveSessionId + ? request + : null; + }); + const acknowledgeWorkflowSessionFocus = useAppStore( + (state) => state.acknowledgeWorkflowSessionFocus, + ); + useEffect(() => { + if (!workflowFocusRequest) return; + handlePanelChangeAndClearSheet("chat"); + acknowledgeWorkflowSessionFocus(workflowFocusRequest.requestId); + }, [acknowledgeWorkflowSessionFocus, handlePanelChangeAndClearSheet, workflowFocusRequest]); const [mobileScrollTarget, setMobileScrollTarget] = useState( null, ); diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-props.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-props.ts index 589e122f40..70fdd15865 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-props.ts +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-props.ts @@ -25,6 +25,7 @@ export function buildMobileTaskSwitcherProps( stepsByWorkflowId: props.stepsByWorkflowId, activeTaskId: props.activeTaskId, selectedTaskId: props.selectedTaskId, + onMoveToStep: props.onMoveToStep, onRequestMoveOptions: props.onRequestMoveOptions, onBeforeMoveOptionsOpen: props.onBeforeMoveOptionsOpen, collapsedGroupKeys: helpers.collapsedGroupKeys, diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx index 6c3bcb901b..70d16f30cd 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx @@ -97,6 +97,7 @@ export type MobileTaskListProps = { activeTaskId: string | null; selectedTaskId: string | null; onSelectTask: (taskId: string) => void; + onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void; onRequestMoveOptions?: (taskId: string, workflowId: string, targetStepId: string) => void; onBeforeMoveOptionsOpen?: () => void; onEditTask?: (task: TaskSwitcherItem) => void; @@ -341,6 +342,7 @@ function TaskSwitcherSurfaceContent({ : data.retryArchivedTasks; const moveOptions = useMobileTaskMoveOptions({ open, + activeTaskId: data.activeTaskId, stepsByWorkflowId: data.stepsByWorkflowId, }); if (moveOptions.moveOptionsStep && moveOptions.request) { @@ -392,6 +394,7 @@ function TaskSwitcherSurfaceContent({ activeTaskId={data.activeTaskId} selectedTaskId={data.selectedTaskId} onSelectTask={actions.handleSelectTask} + onMoveToStep={presentation === "drawer" ? moveOptions.handleMove : undefined} onRequestMoveOptions={presentation === "drawer" ? moveOptions.handleRequest : undefined} onBeforeMoveOptionsOpen={ presentation === "drawer" ? () => onOpenChange(false) : undefined diff --git a/apps/web/components/task/task-move-context-menu.tsx b/apps/web/components/task/task-move-context-menu.tsx index 387cff6f13..35ebe12ff6 100644 --- a/apps/web/components/task/task-move-context-menu.tsx +++ b/apps/web/components/task/task-move-context-menu.tsx @@ -20,7 +20,7 @@ import { type WorkflowMoveOptionsSubmit, } from "./workflow-move-options"; import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; -import type { WorkflowMoveEntryOptions } from "@/lib/api/domains/kanban-api"; +import type { WorkflowMoveEntryOptions, WorkflowMoveResponse } from "@/lib/api/domains/kanban-api"; import type { WorkflowStepProgress } from "@/hooks/domains/kanban/use-workflow-step-progress"; import { StepProgressDetails } from "./workflow-step-progress-details"; @@ -45,11 +45,13 @@ export function useTaskMoveOptions({ workflowId, steps, closeMenu, + onMoveCommitted, }: { taskId: string; workflowId?: string | null; steps?: TaskMoveStep[]; closeMenu?: () => void; + onMoveCommitted?: (response: WorkflowMoveResponse) => void; }) { const [moveOptionsStep, setMoveOptionsStep] = useState(null); const { move, isMoving } = useWorkflowMove(); @@ -87,6 +89,7 @@ export function useTaskMoveOptions({ }); return false; } + onMoveCommitted?.(result.response); closeMenu?.(); return true; }; diff --git a/apps/web/components/task/workflow-stepper-keyboard.test.tsx b/apps/web/components/task/workflow-stepper-keyboard.test.tsx index e8fb54220e..6b45099574 100644 --- a/apps/web/components/task/workflow-stepper-keyboard.test.tsx +++ b/apps/web/components/task/workflow-stepper-keyboard.test.tsx @@ -13,6 +13,11 @@ const { appStoreState, moveTaskMock, previewWorkflowMoveMock } = vi.hoisted(() = workspaceContextGeneration: 1, workflows: { items: [], activeId: null }, tasks: { activeSessionId: null }, + taskRemoval: { navigationRevision: 0 }, + beginWorkflowSessionFocus: vi.fn(() => 1), + bindWorkflowSessionFocus: vi.fn(), + reconcileWorkflowSessionFocus: vi.fn(), + cancelWorkflowSessionFocus: vi.fn(), chatInput: { planModeBySessionId: {} }, kanban: { tasks: [ diff --git a/apps/web/components/task/workflow-stepper.test.tsx b/apps/web/components/task/workflow-stepper.test.tsx index a2e1d7e408..818857f6d0 100644 --- a/apps/web/components/task/workflow-stepper.test.tsx +++ b/apps/web/components/task/workflow-stepper.test.tsx @@ -12,6 +12,11 @@ const { moveTaskMock, previewWorkflowMoveMock, appStoreState } = vi.hoisted(() = workspaceContextGeneration: 1, workflows: { items: [], activeId: null }, tasks: { activeSessionId: null }, + taskRemoval: { navigationRevision: 0 }, + beginWorkflowSessionFocus: vi.fn(() => 1), + bindWorkflowSessionFocus: vi.fn(), + reconcileWorkflowSessionFocus: vi.fn(), + cancelWorkflowSessionFocus: vi.fn(), chatInput: { planModeBySessionId: {} }, kanban: { tasks: [] }, kanbanMulti: { snapshots: {} }, @@ -600,7 +605,7 @@ describe("WorkflowStepper fallback states", () => { // request must not paint a banner describing a move nobody is waiting on. let rejectFirst!: (error: unknown) => void; moveTaskMock.mockReturnValueOnce(new Promise((_res, rej) => (rejectFirst = rej))); - moveTaskMock.mockResolvedValueOnce(undefined); + moveTaskMock.mockResolvedValueOnce({}); const onMoveError = vi.fn(); const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const props = { @@ -626,7 +631,7 @@ describe("WorkflowStepper fallback states", () => { it("notifies the owning surface when a move starts", () => { const onMoveStart = vi.fn(); - moveTaskMock.mockResolvedValueOnce(undefined); + moveTaskMock.mockResolvedValueOnce({}); const props = { steps: STEPS, currentStepId: "b", diff --git a/apps/web/e2e/tests/workflow/mobile-workflow-session-focus.spec.ts b/apps/web/e2e/tests/workflow/mobile-workflow-session-focus.spec.ts new file mode 100644 index 0000000000..0e12372deb --- /dev/null +++ b/apps/web/e2e/tests/workflow/mobile-workflow-session-focus.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; +import { + createWorkflowSessionFocusScenario, + waitForNewWorkflowSession, +} from "./workflow-session-focus-helpers"; + +test.describe("Mobile workflow session focus", () => { + test("shows the new committed recipient in chat without opening the keyboard", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + const scenario = await createWorkflowSessionFocusScenario(apiClient, seedData, "new"); + await testPage.goto(`/t/${scenario.task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + const moveResponse = testPage.waitForResponse( + (response) => + response.request().method() === "POST" && + new URL(response.url()).pathname === `/api/v1/tasks/${scenario.task.id}/move`, + ); + await testPage.getByTestId("mobile-session-menu").tap(); + const sheet = testPage.getByRole("dialog", { name: "Tasks" }); + const taskRow = sheet.locator(`[data-task-row-id="${scenario.task.id}"]`); + await expect(taskRow).toBeVisible(); + await taskRow.getByRole("button", { name: "Task actions" }).tap(); + await testPage.getByTestId("task-context-move-to").tap(); + await testPage.getByTestId(`task-context-step-${scenario.destination.id}`).tap(); + const response = await moveResponse; + expect(response.ok()).toBe(true); + expect((await response.json()).workflow_entry_identity).toMatch(/^entry:/); + + const destinationSessionId = await waitForNewWorkflowSession(apiClient, scenario.task.id, [ + scenario.sourceSessionId, + scenario.secondarySessionId, + ]); + await expect(session.activeChat()).toHaveAttribute("data-session-id", destinationSessionId); + await expect(testPage.getByTestId("mobile-sessions-pill")).toBeVisible(); + await expect(testPage.getByTestId("workflow-step-disclosure")).not.toBeVisible(); + + const focusedEditable = await testPage.evaluate(() => { + const active = document.activeElement; + return active instanceof HTMLElement && active.matches("input, textarea, [contenteditable]"); + }); + expect(focusedEditable).toBe(false); + + const documentWidth = await testPage.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + })); + expect(documentWidth.scrollWidth).toBeLessThanOrEqual(documentWidth.clientWidth + 1); + }); +}); diff --git a/apps/web/e2e/tests/workflow/workflow-session-focus-helpers.ts b/apps/web/e2e/tests/workflow/workflow-session-focus-helpers.ts new file mode 100644 index 0000000000..b1eed815f0 --- /dev/null +++ b/apps/web/e2e/tests/workflow/workflow-session-focus-helpers.ts @@ -0,0 +1,114 @@ +import { expect, type SeedData } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; +import { + createWorkflowAgentProfiles, + waitForWorkflowProfileSession, +} from "./workflow-agent-switch-helpers"; + +export type WorkflowSessionFocusScenario = { + workflow: { id: string }; + source: { id: string; name: string }; + destination: { id: string; name: string }; + task: { id: string }; + sourceSessionId: string; + secondarySessionId: string; +}; + +function destinationProfileOptions(target: "new" | "initial" | "profile", profileBId: string) { + if (target === "new") { + return { + session_target: { kind: "initial" as const }, + profile_session_start_policy: "new" as const, + }; + } + if (target === "initial") return { session_target: { kind: "initial" as const } }; + return { agent_profile_id: profileBId, profile_session_start_policy: "new" as const }; +} + +export async function createWorkflowSessionFocusScenario( + apiClient: ApiClient, + seedData: SeedData, + target: "new" | "initial" | "profile", +): Promise { + const { profileA, profileB } = await createWorkflowAgentProfiles(apiClient); + const workflow = await apiClient.createWorkflow( + seedData.workspaceId, + `Workflow session focus ${target}`, + ); + const source = await apiClient.createWorkflowStep(workflow.id, "Source", 0, { + is_start_step: true, + }); + const destination = await apiClient.createWorkflowStep(workflow.id, "Destination", 1); + + await apiClient.updateWorkflowStep(source.id, { + agent_profile_id: profileA.id, + profile_session_end_policy: "park", + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + await apiClient.updateWorkflowStep(destination.id, { + ...destinationProfileOptions(target, profileB.id), + prompt: `e2e:message("workflow-focus-${target}")`, + profile_session_end_policy: "park", + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + `Workflow session focus ${target} task`, + profileA.id, + { + workflow_id: workflow.id, + workflow_step_id: source.id, + repository_ids: [seedData.repositoryId], + }, + ); + const sourceSessionId = await waitForWorkflowProfileSession(apiClient, task.id, profileA.id); + + const secondary = await apiClient.launchSession({ + task_id: task.id, + agent_profile_id: profileA.id, + executor_profile_id: seedData.worktreeExecutorProfileId, + workflow_step_id: source.id, + prompt: 'e2e:message("workflow-focus-secondary")', + }); + await expect + .poll( + async () => { + const { sessions } = await apiClient.listTaskSessions(task.id); + return sessions.find((session) => session.id === secondary.session_id)?.state; + }, + { timeout: 30_000, message: "secondary focus session never became visible" }, + ) + .not.toBe("CREATED"); + await apiClient.setPrimarySession(sourceSessionId); + + return { + workflow, + source: { ...source, name: "Source" }, + destination: { ...destination, name: "Destination" }, + task, + sourceSessionId, + secondarySessionId: secondary.session_id, + }; +} + +export async function waitForNewWorkflowSession( + apiClient: ApiClient, + taskId: string, + excludedSessionIds: readonly string[], +): Promise { + let sessionId = ""; + await expect + .poll( + async () => { + const { sessions } = await apiClient.listTaskSessions(taskId); + sessionId = + sessions.find((session) => !excludedSessionIds.includes(session.id) && session.is_primary) + ?.id ?? ""; + return sessionId; + }, + { timeout: 30_000, message: "workflow move did not create its destination session" }, + ) + .not.toBe(""); + return sessionId; +} diff --git a/apps/web/e2e/tests/workflow/workflow-session-focus.spec.ts b/apps/web/e2e/tests/workflow/workflow-session-focus.spec.ts new file mode 100644 index 0000000000..1d2f55c7a1 --- /dev/null +++ b/apps/web/e2e/tests/workflow/workflow-session-focus.spec.ts @@ -0,0 +1,213 @@ +import { test, expect } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; +import { KanbanPage } from "../../pages/kanban-page"; +import { waitForHttp } from "../../helpers/causal-waits"; +import { waitForFiniteAnimations } from "../../helpers/animations"; +import { + createWorkflowSessionFocusScenario, + waitForNewWorkflowSession, +} from "./workflow-session-focus-helpers"; + +test.describe("Workflow session focus", () => { + test("focuses a new committed recipient on the task detail page", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + const scenario = await createWorkflowSessionFocusScenario(apiClient, seedData, "new"); + await testPage.goto(`/t/${scenario.task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + await session.sessionTabBySessionId(scenario.secondarySessionId).click(); + await expect(session.activeChat()).toHaveAttribute( + "data-session-id", + scenario.secondarySessionId, + ); + + const moveResponse = waitForHttp( + testPage, + "POST", + new RegExp(`/api/v1/tasks/${scenario.task.id}/move$`), + ); + await session.moveToWorkflowStep(scenario.destination); + const response = await moveResponse; + expect(response.ok()).toBe(true); + expect((await response.json()).workflow_entry_identity).toMatch(/^entry:/); + + const destinationSessionId = await waitForNewWorkflowSession(apiClient, scenario.task.id, [ + scenario.sourceSessionId, + scenario.secondarySessionId, + ]); + await expect(session.sessionTabBySessionId(destinationSessionId)).toBeVisible({ + timeout: 15_000, + }); + await expect(session.activeChat()).toHaveAttribute("data-session-id", destinationSessionId); + await expect(session.sessionTabBySessionId(destinationSessionId)).toBeVisible(); + }); + + test("focuses a reused recipient in the desktop task preview", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + const scenario = await createWorkflowSessionFocusScenario(apiClient, seedData, "initial"); + await apiClient.saveUserSettings({ + enable_preview_on_click: true, + workflow_filter_id: scenario.workflow.id, + }); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + const card = kanban.taskCardByTitle(`Workflow session focus initial task`); + await expect(card).toBeVisible({ timeout: 15_000 }); + await card.click(); + + const preview = testPage.getByTestId("task-preview-panel"); + await expect(preview).toBeVisible({ timeout: 15_000 }); + const secondaryTab = preview.getByTestId(`preview-session-tab-${scenario.secondarySessionId}`); + await expect(secondaryTab).toBeVisible({ timeout: 15_000 }); + await secondaryTab.click(); + await expect(secondaryTab).toHaveAttribute("data-state", "active"); + + const moveResponse = waitForHttp( + testPage, + "POST", + new RegExp(`/api/v1/tasks/${scenario.task.id}/move$`), + ); + await preview.getByTestId("workflow-stepper-minimal").hover(); + const disclosure = testPage.getByTestId("workflow-step-disclosure"); + await expect(disclosure).toBeVisible(); + await waitForFiniteAnimations(testPage.locator('[data-slot="popover-content"]:visible')); + const destinationRow = disclosure.getByTestId( + `workflow-step-disclosure-row-${scenario.destination.id}`, + ); + await expect(destinationRow).toBeVisible(); + const moveButton = destinationRow.getByTestId( + `workflow-step-disclosure-move-${scenario.destination.id}`, + ); + await expect(moveButton).toBeVisible(); + await moveButton.click(); + const response = await moveResponse; + const movePayload = await response.json(); + expect(response.ok()).toBe(true); + expect(movePayload.workflow_entry_identity).toMatch(/^entry:/); + + await expect + .poll(async () => (await apiClient.getTask(scenario.task.id)).workflow_step_id) + .toBe(scenario.destination.id); + await expect + .poll( + async () => + (await apiClient.getTask(scenario.task.id)).metadata?.workflow_session_route?.phase, + ) + .toBe("committed"); + const sourceTab = preview.getByTestId(`preview-session-tab-${scenario.sourceSessionId}`); + await expect(sourceTab).toHaveAttribute("data-state", "active"); + await expect(secondaryTab).toHaveAttribute("data-state", "inactive"); + }); + + test("keeps a later explicit session choice during a delayed move response", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + const scenario = await createWorkflowSessionFocusScenario(apiClient, seedData, "new"); + await testPage.goto(`/t/${scenario.task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.activeChat()).toHaveAttribute("data-session-id", scenario.sourceSessionId); + + let releaseResponse!: () => void; + let responseFetched!: () => void; + const responseReleased = new Promise((resolve) => { + releaseResponse = resolve; + }); + const backendMoveCommitted = new Promise((resolve) => { + responseFetched = resolve; + }); + await testPage.route(`**/api/v1/tasks/${scenario.task.id}/move`, async (route) => { + const response = await route.fetch(); + responseFetched(); + await responseReleased; + await route.fulfill({ response }); + }); + + try { + const moveResponse = waitForHttp( + testPage, + "POST", + new RegExp(`/api/v1/tasks/${scenario.task.id}/move$`), + ); + await session.moveToWorkflowStep(scenario.destination); + await backendMoveCommitted; + + // The destination may be arriving over WS while the HTTP response is + // still held. This click is the user's later, explicit choice. + await session.sessionTabBySessionId(scenario.secondarySessionId).click(); + releaseResponse(); + const response = await moveResponse; + expect(response.ok()).toBe(true); + + const destinationSessionId = await waitForNewWorkflowSession(apiClient, scenario.task.id, [ + scenario.sourceSessionId, + scenario.secondarySessionId, + ]); + await expect(session.sessionTabBySessionId(destinationSessionId)).toBeVisible({ + timeout: 15_000, + }); + await expect(session.activeChat()).toHaveAttribute( + "data-session-id", + scenario.secondarySessionId, + ); + } finally { + releaseResponse(); + await testPage.unroute(`**/api/v1/tasks/${scenario.task.id}/move`); + } + }); + + test("focuses a profile-only recipient instead of the pinned source session", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + const scenario = await createWorkflowSessionFocusScenario(apiClient, seedData, "profile"); + await testPage.goto(`/t/${scenario.task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + await session.sessionTabBySessionId(scenario.secondarySessionId).click(); + await expect(session.activeChat()).toHaveAttribute( + "data-session-id", + scenario.secondarySessionId, + ); + + const moveResponse = waitForHttp( + testPage, + "POST", + new RegExp(`/api/v1/tasks/${scenario.task.id}/move$`), + ); + await session.moveToWorkflowStep(scenario.destination); + const response = await moveResponse; + const payload = await response.json(); + expect(response.ok()).toBe(true); + expect(payload.workflow_entry_identity).toMatch(/^entry:/); + + const destinationSessionId = await waitForNewWorkflowSession(apiClient, scenario.task.id, [ + scenario.sourceSessionId, + scenario.secondarySessionId, + ]); + await expect(session.activeChat()).toHaveAttribute("data-session-id", destinationSessionId); + await expect( + apiClient.getTask(scenario.task.id).then((task) => task.metadata?.workflow_session_route), + ).resolves.toMatchObject({ + phase: "committed", + destination_step_id: scenario.destination.id, + destination_session_id: destinationSessionId, + }); + }); +}); diff --git a/apps/web/hooks/domains/kanban/use-workflow-step-move.test.ts b/apps/web/hooks/domains/kanban/use-workflow-step-move.test.ts index b63ee57db9..3c82d36187 100644 --- a/apps/web/hooks/domains/kanban/use-workflow-step-move.test.ts +++ b/apps/web/hooks/domains/kanban/use-workflow-step-move.test.ts @@ -6,9 +6,14 @@ import { usePresentationToken, useWorkflowStepMove } from "./use-workflow-step-m const mockAppState = vi.hoisted(() => ({ value: { tasks: { activeSessionId: null as string | null }, + taskRemoval: { navigationRevision: 4 }, chatInput: { planModeBySessionId: {} as Record }, setPlanMode: vi.fn(), setActiveDocument: vi.fn(), + beginWorkflowSessionFocus: vi.fn(() => 1), + bindWorkflowSessionFocus: vi.fn(), + reconcileWorkflowSessionFocus: vi.fn(), + cancelWorkflowSessionFocus: vi.fn(), }, })); const mockLayoutStore = vi.hoisted(() => ({ closeDocument: vi.fn() })); @@ -37,11 +42,13 @@ vi.mock("@/lib/api", () => ({ const TASK_ID = "task-1"; const WORKFLOW_ID = "workflow-1"; +const ENTRY_IDENTITY = "entry:00000000000000000042"; afterEach(() => { vi.clearAllMocks(); mockAppState.value.tasks.activeSessionId = null; mockAppState.value.chatInput.planModeBySessionId = {}; + mockAppState.value.taskRemoval.navigationRevision = 4; }); describe("usePresentationToken", () => { @@ -70,6 +77,7 @@ describe("usePresentationToken", () => { }); }); +// eslint-disable-next-line max-lines-per-function -- move races share one hook fixture describe("useWorkflowStepMove", () => { it("issues the move request with the task's workflow id, target step, and position 0", async () => { vi.mocked(moveTask).mockResolvedValue({} as Awaited>); @@ -88,6 +96,102 @@ describe("useWorkflowStepMove", () => { }); }); + it("binds the committed response identity and reconciles its task route", async () => { + const task = { + metadata: { + workflow_session_route: { + operation_id: "operation-1", + destination_step_id: "step-b", + entry_identity: ENTRY_IDENTITY, + destination_session_id: "session-b", + phase: "committed", + }, + }, + }; + vi.mocked(moveTask).mockResolvedValue({ + workflow_entry_identity: ENTRY_IDENTITY, + task, + } as unknown as Awaited>); + const { result } = renderHook(() => + useWorkflowStepMove({ + taskId: TASK_ID, + workflowId: WORKFLOW_ID, + presentationToken: 0, + }), + ); + + await act(async () => { + await result.current.handleMove("step-b"); + }); + + expect(mockAppState.value.beginWorkflowSessionFocus).toHaveBeenCalledWith({ + taskId: TASK_ID, + workflowId: WORKFLOW_ID, + destinationStepId: "step-b", + presentationToken: 0, + navigationRevision: 4, + }); + expect(mockAppState.value.bindWorkflowSessionFocus).toHaveBeenCalledWith({ + requestId: 1, + presentationToken: 0, + entryIdentity: ENTRY_IDENTITY, + }); + expect(mockAppState.value.reconcileWorkflowSessionFocus).toHaveBeenCalledWith(TASK_ID, { + metadata: task.metadata, + updatedAt: undefined, + workflowStepId: undefined, + entryIdentity: ENTRY_IDENTITY, + }); + }); + + it("cancels the local focus intent when the move response has no committed identity", async () => { + vi.mocked(moveTask).mockResolvedValue({} as Awaited>); + const { result, unmount } = renderHook(() => + useWorkflowStepMove({ + taskId: TASK_ID, + workflowId: WORKFLOW_ID, + presentationToken: 0, + }), + ); + + await act(async () => { + await result.current.handleMove("step-b"); + }); + + expect(mockAppState.value.cancelWorkflowSessionFocus).toHaveBeenCalledWith({ + requestId: 1, + presentationToken: 0, + }); + vi.clearAllMocks(); + unmount(); + expect(mockAppState.value.cancelWorkflowSessionFocus).not.toHaveBeenCalled(); + }); + + it("cancels only the active request when the move hook unmounts", async () => { + let resolveMove!: () => void; + vi.mocked(moveTask).mockReturnValueOnce( + new Promise>>((resolve) => { + resolveMove = () => resolve({} as Awaited>); + }), + ); + const { result, unmount } = renderHook(() => + useWorkflowStepMove({ + taskId: TASK_ID, + workflowId: WORKFLOW_ID, + presentationToken: 0, + }), + ); + + act(() => { + void result.current.handleMove("step-b"); + }); + unmount(); + + expect(mockAppState.value.cancelWorkflowSessionFocus).toHaveBeenCalledWith({ requestId: 1 }); + resolveMove(); + await act(async () => {}); + }); + it("notifies onMoveStart and disables plan mode before the request resolves", async () => { mockAppState.value.tasks.activeSessionId = "session-1"; mockAppState.value.chatInput.planModeBySessionId = { "session-1": true }; diff --git a/apps/web/hooks/domains/kanban/use-workflow-step-move.ts b/apps/web/hooks/domains/kanban/use-workflow-step-move.ts index c2eaaf3b06..cd2f1f4153 100644 --- a/apps/web/hooks/domains/kanban/use-workflow-step-move.ts +++ b/apps/web/hooks/domains/kanban/use-workflow-step-move.ts @@ -1,8 +1,8 @@ "use client"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { moveTask } from "@/lib/api"; -import type { WorkflowMoveEntryOptions } from "@/lib/api/domains/kanban-api"; +import type { WorkflowMoveEntryOptions, WorkflowMoveResponse } from "@/lib/api/domains/kanban-api"; import { useAppStore } from "@/components/state-provider"; import { useContextFilesStore } from "@/lib/state/context-files-store"; import { useLayoutStore } from "@/lib/state/layout-store"; @@ -101,6 +101,89 @@ function shouldClearProgressMove( ); } +function useWorkflowStepMoveFocus({ + taskId, + workflowId, + presentationToken, +}: Pick) { + const navigationRevision = useAppStore((s) => s.taskRemoval.navigationRevision); + const beginWorkflowSessionFocus = useAppStore((s) => s.beginWorkflowSessionFocus); + const bindWorkflowSessionFocus = useAppStore((s) => s.bindWorkflowSessionFocus); + const reconcileWorkflowSessionFocus = useAppStore((s) => s.reconcileWorkflowSessionFocus); + const cancelWorkflowSessionFocus = useAppStore((s) => s.cancelWorkflowSessionFocus); + const activeFocusRequestIdRef = useRef(null); + + const beginFocus = useCallback( + (destinationStepId: string) => { + if (!taskId || !workflowId) return null; + const requestId = beginWorkflowSessionFocus({ + taskId, + workflowId, + destinationStepId, + presentationToken, + navigationRevision, + }); + activeFocusRequestIdRef.current = requestId; + return requestId; + }, + [beginWorkflowSessionFocus, navigationRevision, presentationToken, taskId, workflowId], + ); + const commitFocus = useCallback( + (requestId: number | null, response: WorkflowMoveResponse) => { + if (requestId === null) return; + try { + if (!response.workflow_entry_identity) { + cancelWorkflowSessionFocus({ requestId, presentationToken }); + return; + } + bindWorkflowSessionFocus({ + requestId, + presentationToken, + entryIdentity: response.workflow_entry_identity, + }); + if (!taskId) return; + reconcileWorkflowSessionFocus(taskId, { + metadata: response.task?.metadata, + updatedAt: response.task?.updated_at, + workflowStepId: response.task?.workflow_step_id, + entryIdentity: response.workflow_entry_identity, + }); + } finally { + if (activeFocusRequestIdRef.current === requestId) { + activeFocusRequestIdRef.current = null; + } + } + }, + [ + bindWorkflowSessionFocus, + cancelWorkflowSessionFocus, + presentationToken, + reconcileWorkflowSessionFocus, + taskId, + ], + ); + const cancelFocus = useCallback( + (requestId: number | null) => { + if (requestId === null) return; + cancelWorkflowSessionFocus({ requestId, presentationToken }); + if (activeFocusRequestIdRef.current === requestId) { + activeFocusRequestIdRef.current = null; + } + }, + [cancelWorkflowSessionFocus, presentationToken], + ); + + useEffect( + () => () => { + const requestId = activeFocusRequestIdRef.current; + if (requestId !== null) cancelWorkflowSessionFocus({ requestId }); + }, + [cancelWorkflowSessionFocus], + ); + + return { beginFocus, cancelFocus, commitFocus }; +} + /** * The single implementation of the compact stepper's move request. Extracted * so the task top bar and the kanban preview header share one plan-mode @@ -119,6 +202,7 @@ export function useWorkflowStepMove({ onMoveError, }: UseWorkflowStepMoveParams): UseWorkflowStepMoveResult { const disablePlanMode = useDisablePlanMode(); + const focus = useWorkflowStepMoveFocus({ taskId, workflowId, presentationToken }); const [movingToStepId, setMovingToStepId] = useState(null); const [progressingToStepId, setProgressingToStepId] = useState(null); // Only the in-flight step's own button is disabled, so every other step stays @@ -162,6 +246,7 @@ export function useWorkflowStepMove({ const handleMove = useCallback( async (stepId: string, entryOptions?: WorkflowMoveEntryOptions): Promise => { if (!taskId || !workflowId) return false; + const focusRequestId = focus.beginFocus(stepId); onMoveStart?.(); disablePlanMode(); const requestId = ++moveRequestRef.current; @@ -179,15 +264,17 @@ export function useWorkflowStepMove({ setMovingToStepId(stepId); setProgressingToStepId(stepId); try { - await moveTask(taskId, { + const response = await moveTask(taskId, { workflow_id: workflowId, workflow_step_id: stepId, position: 0, entry_options: entryOptions, }); + if (requestId === moveRequestRef.current) focus.commitFocus(focusRequestId, response); return true; } catch (err) { console.error("[useWorkflowStepMove] Failed to move task:", err); + focus.cancelFocus(focusRequestId); if (requestId === moveRequestRef.current) { progressMoveRef.current = null; priorMoveTargetsRef.current.clear(); @@ -201,7 +288,7 @@ export function useWorkflowStepMove({ if (requestId === moveRequestRef.current) setMovingToStepId(null); } }, - [taskId, workflowId, currentStepId, disablePlanMode, onMoveStart, onMoveError], + [taskId, workflowId, currentStepId, focus, disablePlanMode, onMoveStart, onMoveError], ); return { movingToStepId, progressingToStepId, handleMove }; diff --git a/apps/web/hooks/use-task-sessions.ts b/apps/web/hooks/use-task-sessions.ts index 4b337ab558..9bce293e8c 100644 --- a/apps/web/hooks/use-task-sessions.ts +++ b/apps/web/hooks/use-task-sessions.ts @@ -49,6 +49,7 @@ async function hydrateTaskSessions({ [...fetchedSessions, ...sessionsAddedDuringLoad], activityEpochsAtRequestStart, ); + getStoreState().reconcileWorkflowSessionFocus?.(taskId); return sessionsAddedDuringLoad.length > 0; } catch (error) { console.error("Failed to load task sessions:", error); @@ -58,6 +59,7 @@ async function hydrateTaskSessions({ const currentSessions = storedTaskSessions(getStoreState, taskId); if (!force && currentSessions.length > 0) { setTaskSessionsForTask(taskId, currentSessions, activityEpochsAtRequestStart); + getStoreState().reconcileWorkflowSessionFocus?.(taskId); } setTaskSessionsError(taskId, error instanceof Error ? error.message : String(error)); return false; diff --git a/apps/web/lib/state/default-state.ts b/apps/web/lib/state/default-state.ts index 33cc30613e..1dccbf08f6 100644 --- a/apps/web/lib/state/default-state.ts +++ b/apps/web/lib/state/default-state.ts @@ -49,6 +49,7 @@ export const defaultState = { workspaceContextGeneration: defaultKanbanState.workspaceContextGeneration, workspaceContextRead: defaultKanbanState.workspaceContextRead, tasks: defaultKanbanState.tasks, + workflowSessionFocus: defaultKanbanState.workflowSessionFocus, taskRemoval: defaultKanbanState.taskRemoval, workspaces: defaultWorkspaceState.workspaces, repositories: defaultWorkspaceState.repositories, @@ -431,6 +432,7 @@ function mergeTaskSessionState(initialState: HydrationState) { export function mergeInitialState(initialState?: HydrationState): DefaultState { if (!initialState) return defaultState; const hydration = { ...initialState }; + delete hydration.workflowSessionFocus; delete hydration.taskRemoval; return { ...defaultState, diff --git a/apps/web/lib/state/slices/kanban/kanban-slice.ts b/apps/web/lib/state/slices/kanban/kanban-slice.ts index e208b7e767..700a27c191 100644 --- a/apps/web/lib/state/slices/kanban/kanban-slice.ts +++ b/apps/web/lib/state/slices/kanban/kanban-slice.ts @@ -10,6 +10,17 @@ import { releaseTaskRemoval, } from "@/lib/state/task-removal"; import { mergeStepOrderRevisions } from "@/lib/kanban/workflow-step-order"; +import { + acknowledgeWorkflowSessionFocus, + beginWorkflowSessionFocus, + bindWorkflowSessionFocus, + cancelWorkflowSessionFocus, + createWorkflowSessionFocusState, + reconcileWorkflowSessionFocus, + type WorkflowSessionFocusCancelScope, + type WorkflowSessionFocusStart, +} from "@/lib/state/workflow-session-focus"; +import { parseTurnTimestamp } from "@/lib/state/slices/session/turn-actions"; import { WORKSPACE_CONTEXT_COLLECTIONS, type WorkspaceContextCollection, @@ -73,12 +84,16 @@ export const defaultKanbanState: KanbanSliceState = { lastSessionByTaskId: {}, resumeSkippedSessionIds: {}, }, + workflowSessionFocus: createWorkflowSessionFocusState(), taskRemoval: createTaskRemovalState(), }; type KanbanSliceSet = Parameters< StateCreator >[0]; +type KanbanSliceGet = Parameters< + StateCreator +>[1]; type SidebarArchivedTaskActions = Pick< KanbanSliceActions, @@ -153,6 +168,11 @@ type TaskActions = Pick< | "setActiveSession" | "setActiveSessionAuto" | "clearActiveSession" + | "beginWorkflowSessionFocus" + | "bindWorkflowSessionFocus" + | "reconcileWorkflowSessionFocus" + | "cancelWorkflowSessionFocus" + | "acknowledgeWorkflowSessionFocus" | "setResumeSkipped" | "beginTaskRemoval" | "recordTaskRemovalResult" @@ -292,10 +312,118 @@ function createWorkspaceContextActions(set: KanbanSliceSet): WorkspaceContextAct }; } -function createTaskActions(set: KanbanSliceSet): TaskActions { +type FocusLookupState = { + taskSessionsByTask?: { + itemsByTaskId?: Record>; + }; + taskSessions?: { + items?: Record; + }; +}; + +type WorkflowSessionFocusActions = Pick< + TaskActions, + | "beginWorkflowSessionFocus" + | "bindWorkflowSessionFocus" + | "reconcileWorkflowSessionFocus" + | "cancelWorkflowSessionFocus" + | "acknowledgeWorkflowSessionFocus" +>; + +function createWorkflowSessionFocusActions( + set: KanbanSliceSet, + get: KanbanSliceGet, +): WorkflowSessionFocusActions { + const knownSessionIdsForTask = (taskId: string): string[] => { + const state = get() as KanbanSlice & FocusLookupState; + const ids = new Set( + state.taskSessionsByTask?.itemsByTaskId?.[taskId]?.map((session) => session.id) ?? [], + ); + for (const session of Object.values(state.taskSessions?.items ?? {})) { + if (session.task_id === taskId) ids.add(session.id); + } + return [...ids]; + }; + + const taskProjectionForTask = ( + taskId: string, + ): { metadata: unknown; updatedAt?: string; workflowStepId?: string } => { + const state = get(); + const tasks = [ + ...state.kanban.tasks, + ...Object.values(state.kanbanMulti.snapshots).flatMap((snapshot) => snapshot.tasks), + ].filter((candidate) => candidate.id === taskId); + let task = tasks[0]; + for (const candidate of tasks.slice(1)) { + const selectedTime = parseTurnTimestamp(task?.updatedAt); + const candidateTime = parseTurnTimestamp(candidate.updatedAt); + if ( + task === undefined || + (selectedTime === null && candidateTime !== null) || + (candidateTime !== null && selectedTime !== null && candidateTime > selectedTime) + ) { + task = candidate; + } + } + return { + metadata: task?.metadata, + updatedAt: task?.updatedAt, + workflowStepId: task?.workflowStepId, + }; + }; + return { + beginWorkflowSessionFocus: (input: WorkflowSessionFocusStart) => { + let requestId: number | null = null; + set((draft) => { + const result = beginWorkflowSessionFocus(draft.workflowSessionFocus, input); + draft.workflowSessionFocus = result.state; + requestId = result.requestId; + }); + return requestId; + }, + bindWorkflowSessionFocus: (input) => + set((draft) => { + draft.workflowSessionFocus = bindWorkflowSessionFocus(draft.workflowSessionFocus, input); + }), + reconcileWorkflowSessionFocus: (taskId, responseProjection) => + set((draft) => { + const liveProjection = taskProjectionForTask(taskId); + const result = reconcileWorkflowSessionFocus(draft.workflowSessionFocus, { + activeTaskId: draft.tasks.activeTaskId, + navigationRevision: draft.taskRemoval.navigationRevision, + routeMetadata: liveProjection.metadata, + routeUpdatedAt: liveProjection.updatedAt, + workflowStepId: liveProjection.workflowStepId, + responseProjection, + knownSessionIds: knownSessionIdsForTask(taskId), + }); + draft.workflowSessionFocus = result.state; + if (!result.sessionId) return; + draft.tasks.activeSessionId = result.sessionId; + draft.tasks.pinnedSessionId = null; + draft.tasks.lastSessionByTaskId[taskId] = result.sessionId; + }), + cancelWorkflowSessionFocus: (scope?: WorkflowSessionFocusCancelScope) => + set((draft) => { + draft.workflowSessionFocus = cancelWorkflowSessionFocus(draft.workflowSessionFocus, scope); + }), + acknowledgeWorkflowSessionFocus: (requestId) => + set((draft) => { + draft.workflowSessionFocus = acknowledgeWorkflowSessionFocus( + draft.workflowSessionFocus, + requestId, + ); + }), + }; +} + +function createTaskActions(set: KanbanSliceSet, get: KanbanSliceGet): TaskActions { + return { + ...createWorkflowSessionFocusActions(set, get), setActiveTask: (taskId) => set((draft) => { + draft.workflowSessionFocus = cancelWorkflowSessionFocus(draft.workflowSessionFocus); draft.taskRemoval = advanceTaskNavigationRevision(draft.taskRemoval); draft.tasks.activeTaskId = taskId; draft.tasks.activeSessionId = null; @@ -304,12 +432,16 @@ function createTaskActions(set: KanbanSliceSet): TaskActions { }), setActiveTaskAuto: (taskId) => set((draft) => { + if (draft.tasks.activeTaskId !== taskId) { + draft.workflowSessionFocus = cancelWorkflowSessionFocus(draft.workflowSessionFocus); + } draft.tasks.activeTaskId = taskId; draft.tasks.activeSessionId = null; draft.tasks.pinnedSessionId = null; }), setActiveSession: (taskId, sessionId) => set((draft) => { + draft.workflowSessionFocus = cancelWorkflowSessionFocus(draft.workflowSessionFocus); draft.taskRemoval = advanceTaskNavigationRevision(draft.taskRemoval); draft.tasks.activeTaskId = taskId; draft.tasks.activeSessionId = sessionId; @@ -320,6 +452,7 @@ function createTaskActions(set: KanbanSliceSet): TaskActions { setActiveSessionAuto: (taskId, sessionId) => set((draft) => { if (draft.tasks.activeTaskId !== taskId) { + draft.workflowSessionFocus = cancelWorkflowSessionFocus(draft.workflowSessionFocus); draft.tasks.pinnedSessionId = null; } draft.tasks.activeTaskId = taskId; @@ -347,10 +480,12 @@ function createTaskActions(set: KanbanSliceSet): TaskActions { }), advanceTaskNavigationRevision: () => set((draft) => { + draft.workflowSessionFocus = cancelWorkflowSessionFocus(draft.workflowSessionFocus); draft.taskRemoval = advanceTaskNavigationRevision(draft.taskRemoval); }), clearActiveSession: () => set((draft) => { + draft.workflowSessionFocus = cancelWorkflowSessionFocus(draft.workflowSessionFocus); draft.tasks.activeSessionId = null; draft.tasks.pinnedSessionId = null; }), @@ -374,10 +509,10 @@ export const createKanbanSlice: StateCreator< [["zustand/immer", never]], [], KanbanSlice -> = (set) => ({ +> = (set, get) => ({ ...defaultKanbanState, ...createSidebarArchivedTaskActions(set), - ...createTaskActions(set), + ...createTaskActions(set, get), ...createWorkspaceContextActions(set), resetKanbanWorkspaceContext: () => set((draft) => { diff --git a/apps/web/lib/state/slices/kanban/types.ts b/apps/web/lib/state/slices/kanban/types.ts index b0c5beb88a..ad22de38d7 100644 --- a/apps/web/lib/state/slices/kanban/types.ts +++ b/apps/web/lib/state/slices/kanban/types.ts @@ -12,6 +12,12 @@ import type { } from "@/lib/types/http"; import type { TaskStatusSummary } from "@/lib/types/task-status-summary"; import type { BeginTaskRemovalInput, TaskRemovalState } from "@/lib/state/task-removal"; +import type { + WorkflowSessionFocusCancelScope, + WorkflowSessionFocusState, + WorkflowSessionFocusStart, + WorkflowSessionFocusTaskProjection, +} from "@/lib/state/workflow-session-focus"; export type KanbanStepEvents = { on_enter?: Array<{ type: string; config?: Record }>; @@ -326,6 +332,8 @@ export type KanbanSliceState = { workspaceContextGeneration: number; workspaceContextRead: WorkspaceContextReadState; tasks: TaskState; + /** Browser-local one-shot focus handoff for an explicitly moved task. */ + workflowSessionFocus: WorkflowSessionFocusState; /** Browser-local removal intent. It is deliberately excluded from hydration. */ taskRemoval: TaskRemovalState; }; @@ -361,6 +369,18 @@ export type KanbanSliceActions = { // it explicitly after checking no non-terminal manual pin should be preserved. setActiveSessionAuto: (taskId: string, sessionId: string) => void; clearActiveSession: () => void; + beginWorkflowSessionFocus: (input: WorkflowSessionFocusStart) => number | null; + bindWorkflowSessionFocus: (input: { + requestId: number; + presentationToken: number; + entryIdentity: string; + }) => void; + reconcileWorkflowSessionFocus: ( + taskId: string, + responseProjection?: WorkflowSessionFocusTaskProjection, + ) => void; + cancelWorkflowSessionFocus: (scope?: WorkflowSessionFocusCancelScope) => void; + acknowledgeWorkflowSessionFocus: (requestId: number) => void; // setResumeSkipped records/clears the resume-skipped marker for a session. // Recording is guarded at the call site (the session-resumption hook reads // the live session row with typed store access), so a stale status response diff --git a/apps/web/lib/state/store-overrides.ts b/apps/web/lib/state/store-overrides.ts index 4cf2a19674..47fb5ce41c 100644 --- a/apps/web/lib/state/store-overrides.ts +++ b/apps/web/lib/state/store-overrides.ts @@ -13,6 +13,7 @@ export function buildStateOverrides(m: DefaultState) { workspaceContextGeneration: m.workspaceContextGeneration, workspaceContextRead: m.workspaceContextRead, tasks: m.tasks, + workflowSessionFocus: m.workflowSessionFocus, taskRemoval: m.taskRemoval, workspaces: m.workspaces, repositories: m.repositories, diff --git a/apps/web/lib/state/workflow-session-focus.test.ts b/apps/web/lib/state/workflow-session-focus.test.ts new file mode 100644 index 0000000000..e8f0806277 --- /dev/null +++ b/apps/web/lib/state/workflow-session-focus.test.ts @@ -0,0 +1,490 @@ +import { describe, expect, it } from "vitest"; +import { createAppStore } from "./store"; +import { + acknowledgeWorkflowSessionFocus, + bindWorkflowSessionFocus, + cancelWorkflowSessionFocus, + createWorkflowSessionFocusState, + beginWorkflowSessionFocus, + reconcileWorkflowSessionFocus, +} from "./workflow-session-focus"; + +const START = { + taskId: "task-1", + workflowId: "workflow-1", + destinationStepId: "step-implement", + presentationToken: 7, + navigationRevision: 3, +}; +const SOURCE_SESSION_ID = "session-astra"; +const DESTINATION_SESSION_ID = "session-luna"; +const NEWER_SESSION_ID = "session-newer"; +const LATER_STEP_ID = "step-review"; +const LIVE_UPDATED_AT = "2026-09-19T10:00:02Z"; +const RESPONSE_UPDATED_AT = "2026-09-19T10:00:01Z"; +const FOCUS_INTENT_ERROR = "test focus intent did not start"; + +const COMMITTED_ROUTE = { + operation_id: "operation-1", + destination_step_id: "step-implement", + entry_identity: "entry:00000000000000000042", + destination_session_id: DESTINATION_SESSION_ID, + phase: "committed", +}; + +function started(): { + state: ReturnType; + requestId: number; +} { + const result = beginWorkflowSessionFocus(createWorkflowSessionFocusState(), START); + if (result.requestId === null) throw new Error(FOCUS_INTENT_ERROR); + return { state: result.state, requestId: result.requestId }; +} + +// eslint-disable-next-line max-lines-per-function -- state-machine scenarios share one transition fixture +describe("workflow session focus state", () => { + it("applies the resolved recipient atomically in the app store and clears the old pin", () => { + const store = createAppStore(); + store.setState((state) => { + state.tasks.activeTaskId = START.taskId; + state.tasks.activeSessionId = SOURCE_SESSION_ID; + state.tasks.pinnedSessionId = SOURCE_SESSION_ID; + state.taskRemoval.navigationRevision = START.navigationRevision; + state.kanban.tasks = [ + { + id: START.taskId, + workflowStepId: START.destinationStepId, + metadata: { workflow_session_route: COMMITTED_ROUTE }, + }, + ] as never; + state.taskSessionsByTask.itemsByTaskId[START.taskId] = [ + { id: SOURCE_SESSION_ID }, + { id: DESTINATION_SESSION_ID }, + ] as never; + state.taskSessions.items[DESTINATION_SESSION_ID] = { + id: DESTINATION_SESSION_ID, + task_id: START.taskId, + } as never; + }); + + const requestId = store.getState().beginWorkflowSessionFocus(START); + expect(requestId).toBe(1); + if (requestId === null) throw new Error(FOCUS_INTENT_ERROR); + store.getState().bindWorkflowSessionFocus({ + requestId, + presentationToken: START.presentationToken, + entryIdentity: COMMITTED_ROUTE.entry_identity, + }); + store.getState().reconcileWorkflowSessionFocus(START.taskId); + + expect(store.getState().tasks.activeSessionId).toBe(DESTINATION_SESSION_ID); + expect(store.getState().tasks.pinnedSessionId).toBeNull(); + expect(store.getState().tasks.lastSessionByTaskId[START.taskId]).toBe(DESTINATION_SESSION_ID); + expect(store.getState().workflowSessionFocus.request?.sessionId).toBe(DESTINATION_SESSION_ID); + + store.getState().setActiveSession(START.taskId, SOURCE_SESSION_ID); + expect(store.getState().workflowSessionFocus.request).toBeNull(); + }); + + it("reconsiders the cached live route when a move response is delayed", () => { + const store = createAppStore(); + store.setState((state) => { + state.tasks.activeTaskId = START.taskId; + state.tasks.activeSessionId = SOURCE_SESSION_ID; + state.taskRemoval.navigationRevision = START.navigationRevision; + state.kanban.tasks = [ + { + id: START.taskId, + workflowStepId: START.destinationStepId, + updatedAt: LIVE_UPDATED_AT, + metadata: { workflow_session_route: COMMITTED_ROUTE }, + }, + ] as never; + state.taskSessionsByTask.itemsByTaskId[START.taskId] = [ + { id: SOURCE_SESSION_ID }, + { id: DESTINATION_SESSION_ID }, + ] as never; + }); + + const requestId = store.getState().beginWorkflowSessionFocus(START); + if (requestId === null) throw new Error(FOCUS_INTENT_ERROR); + store.getState().bindWorkflowSessionFocus({ + requestId, + presentationToken: START.presentationToken, + entryIdentity: COMMITTED_ROUTE.entry_identity, + }); + store.getState().reconcileWorkflowSessionFocus(START.taskId, { + metadata: { workflow_session_route: { ...COMMITTED_ROUTE, phase: "prepared" } }, + updatedAt: RESPONSE_UPDATED_AT, + entryIdentity: COMMITTED_ROUTE.entry_identity, + }); + + expect(store.getState().tasks.activeSessionId).toBe(DESTINATION_SESSION_ID); + expect(store.getState().workflowSessionFocus.request?.sessionId).toBe(DESTINATION_SESSION_ID); + }); + + it("accepts the response projection when the live task is still on the source step", () => { + const startedState = started(); + const bound = bindWorkflowSessionFocus(startedState.state, { + requestId: startedState.requestId, + presentationToken: START.presentationToken, + entryIdentity: COMMITTED_ROUTE.entry_identity, + }); + + const result = reconcileWorkflowSessionFocus(bound, { + activeTaskId: START.taskId, + navigationRevision: START.navigationRevision, + workflowStepId: "step-plan", + routeMetadata: undefined, + routeUpdatedAt: "2026-09-19T10:00:00Z", + responseProjection: { + metadata: { workflow_session_route: COMMITTED_ROUTE }, + updatedAt: RESPONSE_UPDATED_AT, + workflowStepId: START.destinationStepId, + entryIdentity: COMMITTED_ROUTE.entry_identity, + }, + knownSessionIds: [DESTINATION_SESSION_ID], + }); + + expect(result.sessionId).toBe(DESTINATION_SESSION_ID); + }); + + it("keeps an intent pending when its response route is older than a newer live entry", () => { + const store = createAppStore(); + const newerLiveRoute = { + ...COMMITTED_ROUTE, + entry_identity: "entry:00000000000000000043", + destination_session_id: NEWER_SESSION_ID, + }; + store.setState((state) => { + state.tasks.activeTaskId = START.taskId; + state.tasks.activeSessionId = SOURCE_SESSION_ID; + state.taskRemoval.navigationRevision = START.navigationRevision; + state.kanban.tasks = [ + { + id: START.taskId, + workflowStepId: START.destinationStepId, + updatedAt: LIVE_UPDATED_AT, + metadata: { workflow_session_route: newerLiveRoute }, + }, + ] as never; + state.taskSessionsByTask.itemsByTaskId[START.taskId] = [ + { id: SOURCE_SESSION_ID }, + { id: DESTINATION_SESSION_ID }, + { id: NEWER_SESSION_ID }, + ] as never; + }); + + const requestId = store.getState().beginWorkflowSessionFocus(START); + if (requestId === null) throw new Error(FOCUS_INTENT_ERROR); + store.getState().bindWorkflowSessionFocus({ + requestId, + presentationToken: START.presentationToken, + entryIdentity: COMMITTED_ROUTE.entry_identity, + }); + store.getState().reconcileWorkflowSessionFocus(START.taskId, { + metadata: { workflow_session_route: COMMITTED_ROUTE }, + updatedAt: RESPONSE_UPDATED_AT, + entryIdentity: COMMITTED_ROUTE.entry_identity, + }); + + expect(store.getState().tasks.activeSessionId).toBe(SOURCE_SESSION_ID); + expect(store.getState().workflowSessionFocus.intent).not.toBeNull(); + expect(store.getState().workflowSessionFocus.request).toBeNull(); + }); + + it("binds a response entry and resolves it only for the committed task-owned recipient", () => { + const startedState = started(); + expect(startedState.requestId).toBe(1); + + const bound = bindWorkflowSessionFocus(startedState.state, { + requestId: startedState.requestId, + presentationToken: START.presentationToken, + entryIdentity: COMMITTED_ROUTE.entry_identity, + }); + const unresolved = reconcileWorkflowSessionFocus(bound, { + activeTaskId: START.taskId, + navigationRevision: START.navigationRevision, + workflowStepId: START.destinationStepId, + routeMetadata: { workflow_session_route: COMMITTED_ROUTE }, + knownSessionIds: [SOURCE_SESSION_ID], + }); + + expect(unresolved.sessionId).toBeNull(); + expect(unresolved.state.intent).not.toBeNull(); + + const resolved = reconcileWorkflowSessionFocus(bound, { + activeTaskId: START.taskId, + navigationRevision: START.navigationRevision, + workflowStepId: START.destinationStepId, + routeMetadata: { workflow_session_route: COMMITTED_ROUTE }, + knownSessionIds: [SOURCE_SESSION_ID, DESTINATION_SESSION_ID], + }); + + expect(resolved.sessionId).toBe(DESTINATION_SESSION_ID); + expect(resolved.state.intent).toBeNull(); + expect(resolved.state.request).toEqual({ + requestId: startedState.requestId, + presentationToken: START.presentationToken, + taskId: START.taskId, + sessionId: DESTINATION_SESSION_ID, + }); + }); + + it("rejects prepared, foreign, stale, and mismatched routes", () => { + const startedState = started(); + const bound = bindWorkflowSessionFocus(startedState.state, { + requestId: startedState.requestId, + presentationToken: START.presentationToken, + entryIdentity: COMMITTED_ROUTE.entry_identity, + }); + + for (const routeMetadata of [ + { workflow_session_route: { ...COMMITTED_ROUTE, phase: "prepared" } }, + { workflow_session_route: { ...COMMITTED_ROUTE, entry_identity: "entry:other" } }, + { workflow_session_route: { ...COMMITTED_ROUTE, destination_step_id: "step-review" } }, + { workflow_session_route: { ...COMMITTED_ROUTE, destination_session_id: "session-other" } }, + { workflow_session_route: { ...COMMITTED_ROUTE, operation_id: "" } }, + ]) { + const result = reconcileWorkflowSessionFocus(bound, { + activeTaskId: START.taskId, + navigationRevision: START.navigationRevision, + workflowStepId: START.destinationStepId, + routeMetadata, + knownSessionIds: [DESTINATION_SESSION_ID], + }); + expect(result.sessionId).toBeNull(); + expect(result.state.intent).not.toBeNull(); + } + }); + + it("cancels on a later navigation, newer request, and a missing response identity", () => { + const first = started(); + const canceled = cancelWorkflowSessionFocus(first.state, { + taskId: START.taskId, + presentationToken: START.presentationToken, + }); + expect(canceled.intent).toBeNull(); + + const second = beginWorkflowSessionFocus(canceled, { ...START, presentationToken: 8 }); + const newer = beginWorkflowSessionFocus(second.state, { + ...START, + destinationStepId: LATER_STEP_ID, + }); + expect(newer.requestId).toBe(3); + expect(newer.state.intent?.destinationStepId).toBe(LATER_STEP_ID); + if (newer.requestId === null) throw new Error("test focus intent did not restart"); + + const missingIdentity = bindWorkflowSessionFocus(newer.state, { + requestId: newer.requestId, + presentationToken: START.presentationToken, + entryIdentity: "", + }); + expect(missingIdentity.intent).toBeNull(); + expect(missingIdentity.request).toBeNull(); + }); + + it("keeps a focus request until its desktop or phone consumer acknowledges it", () => { + const startedState = started(); + const bound = bindWorkflowSessionFocus(startedState.state, { + requestId: startedState.requestId, + presentationToken: START.presentationToken, + entryIdentity: COMMITTED_ROUTE.entry_identity, + }); + const resolved = reconcileWorkflowSessionFocus(bound, { + activeTaskId: START.taskId, + navigationRevision: START.navigationRevision, + workflowStepId: START.destinationStepId, + routeMetadata: { workflow_session_route: COMMITTED_ROUTE }, + knownSessionIds: [DESTINATION_SESSION_ID], + }); + + const acknowledged = acknowledgeWorkflowSessionFocus(resolved.state, startedState.requestId); + expect(acknowledged.request).toBeNull(); + }); + + it("uses the live committed route when the response arrives after its task event", () => { + const startedState = started(); + const bound = bindWorkflowSessionFocus(startedState.state, { + requestId: startedState.requestId, + presentationToken: START.presentationToken, + entryIdentity: COMMITTED_ROUTE.entry_identity, + }); + + const resolved = reconcileWorkflowSessionFocus(bound, { + activeTaskId: START.taskId, + navigationRevision: START.navigationRevision, + workflowStepId: START.destinationStepId, + routeMetadata: { workflow_session_route: COMMITTED_ROUTE }, + routeUpdatedAt: LIVE_UPDATED_AT, + responseProjection: { + metadata: { + workflow_session_route: { ...COMMITTED_ROUTE, phase: "prepared" }, + }, + updatedAt: RESPONSE_UPDATED_AT, + entryIdentity: COMMITTED_ROUTE.entry_identity, + }, + knownSessionIds: [DESTINATION_SESSION_ID], + }); + + expect(resolved.sessionId).toBe(DESTINATION_SESSION_ID); + }); + + it("rejects an obsolete response route after a newer live entry", () => { + const startedState = started(); + const bound = bindWorkflowSessionFocus(startedState.state, { + requestId: startedState.requestId, + presentationToken: START.presentationToken, + entryIdentity: COMMITTED_ROUTE.entry_identity, + }); + const newerLiveRoute = { + ...COMMITTED_ROUTE, + entry_identity: "entry:00000000000000000043", + destination_session_id: NEWER_SESSION_ID, + }; + + const result = reconcileWorkflowSessionFocus(bound, { + activeTaskId: START.taskId, + navigationRevision: START.navigationRevision, + workflowStepId: START.destinationStepId, + routeMetadata: { workflow_session_route: newerLiveRoute }, + routeUpdatedAt: LIVE_UPDATED_AT, + responseProjection: { + metadata: { workflow_session_route: COMMITTED_ROUTE }, + updatedAt: RESPONSE_UPDATED_AT, + entryIdentity: COMMITTED_ROUTE.entry_identity, + }, + knownSessionIds: [DESTINATION_SESSION_ID, NEWER_SESSION_ID], + }); + + expect(result.sessionId).toBeNull(); + expect(result.state.intent).not.toBeNull(); + }); + + it("accepts a committed response only when its entry identity matches the bound move", () => { + const startedState = started(); + const bound = bindWorkflowSessionFocus(startedState.state, { + requestId: startedState.requestId, + presentationToken: START.presentationToken, + entryIdentity: COMMITTED_ROUTE.entry_identity, + }); + + const result = reconcileWorkflowSessionFocus(bound, { + activeTaskId: START.taskId, + navigationRevision: START.navigationRevision, + workflowStepId: START.destinationStepId, + routeMetadata: undefined, + responseProjection: { + metadata: { workflow_session_route: COMMITTED_ROUTE }, + updatedAt: RESPONSE_UPDATED_AT, + entryIdentity: "entry:other", + }, + knownSessionIds: [DESTINATION_SESSION_ID], + }); + + expect(result.sessionId).toBeNull(); + expect(result.state.intent).not.toBeNull(); + }); + + it("rejects a committed route after the task reaches a later workflow step", () => { + const startedState = started(); + const bound = bindWorkflowSessionFocus(startedState.state, { + requestId: startedState.requestId, + presentationToken: START.presentationToken, + entryIdentity: COMMITTED_ROUTE.entry_identity, + }); + + const result = reconcileWorkflowSessionFocus(bound, { + activeTaskId: START.taskId, + navigationRevision: START.navigationRevision, + workflowStepId: LATER_STEP_ID, + routeMetadata: { workflow_session_route: COMMITTED_ROUTE }, + knownSessionIds: [DESTINATION_SESSION_ID], + }); + + expect(result.sessionId).toBeNull(); + expect(result.state.intent).toBeNull(); + }); + + it.each(["0", "2026-02-30T10:00:00Z"])( + "ignores malformed response timestamp %s when choosing a live route", + (updatedAt) => { + const startedState = started(); + const bound = bindWorkflowSessionFocus(startedState.state, { + requestId: startedState.requestId, + presentationToken: START.presentationToken, + entryIdentity: COMMITTED_ROUTE.entry_identity, + }); + + const result = reconcileWorkflowSessionFocus(bound, { + activeTaskId: START.taskId, + navigationRevision: START.navigationRevision, + workflowStepId: START.destinationStepId, + routeMetadata: { workflow_session_route: COMMITTED_ROUTE }, + routeUpdatedAt: LIVE_UPDATED_AT, + responseProjection: { + metadata: { + workflow_session_route: { ...COMMITTED_ROUTE, phase: "prepared" }, + }, + updatedAt, + entryIdentity: COMMITTED_ROUTE.entry_identity, + }, + knownSessionIds: [DESTINATION_SESSION_ID], + }); + + expect(result.sessionId).toBe(DESTINATION_SESSION_ID); + }, + ); + + it("selects a valid snapshot over a normalized-invalid task timestamp", () => { + const store = createAppStore(); + const wrongRoute = { + ...COMMITTED_ROUTE, + destination_session_id: NEWER_SESSION_ID, + }; + store.setState((state) => { + state.tasks.activeTaskId = START.taskId; + state.tasks.activeSessionId = SOURCE_SESSION_ID; + state.taskRemoval.navigationRevision = START.navigationRevision; + state.kanban.tasks = [ + { + id: START.taskId, + workflowStepId: START.destinationStepId, + updatedAt: "2026-02-30T10:00:00Z", + metadata: { workflow_session_route: wrongRoute }, + }, + ] as never; + state.kanbanMulti.snapshots = { + [START.workflowId]: { + workflowId: START.workflowId, + workflowName: "Workflow", + steps: [], + tasks: [ + { + id: START.taskId, + workflowStepId: START.destinationStepId, + updatedAt: "2026-03-01T10:00:00Z", + metadata: { workflow_session_route: COMMITTED_ROUTE }, + }, + ], + }, + } as never; + state.taskSessionsByTask.itemsByTaskId[START.taskId] = [ + { id: SOURCE_SESSION_ID }, + { id: DESTINATION_SESSION_ID }, + { id: NEWER_SESSION_ID }, + ] as never; + }); + + const requestId = store.getState().beginWorkflowSessionFocus(START); + if (requestId === null) throw new Error(FOCUS_INTENT_ERROR); + store.getState().bindWorkflowSessionFocus({ + requestId, + presentationToken: START.presentationToken, + entryIdentity: COMMITTED_ROUTE.entry_identity, + }); + store.getState().reconcileWorkflowSessionFocus(START.taskId); + + expect(store.getState().tasks.activeSessionId).toBe(DESTINATION_SESSION_ID); + }); +}); diff --git a/apps/web/lib/state/workflow-session-focus.ts b/apps/web/lib/state/workflow-session-focus.ts new file mode 100644 index 0000000000..7085f263fe --- /dev/null +++ b/apps/web/lib/state/workflow-session-focus.ts @@ -0,0 +1,281 @@ +import { parseTurnTimestamp } from "@/lib/state/slices/session/turn-actions"; + +export type WorkflowSessionFocusStart = { + taskId: string; + workflowId: string; + destinationStepId: string; + presentationToken: number; + navigationRevision: number; +}; + +export type WorkflowSessionFocusIntent = WorkflowSessionFocusStart & { + requestId: number; + entryIdentity: string | null; +}; + +export type WorkflowSessionFocusRequest = { + requestId: number; + presentationToken: number; + taskId: string; + sessionId: string; +}; + +export type WorkflowSessionFocusState = { + nextRequestId: number; + intent: WorkflowSessionFocusIntent | null; + request: WorkflowSessionFocusRequest | null; +}; + +export type WorkflowSessionFocusCancelScope = { + requestId?: number; + presentationToken?: number; + taskId?: string; +}; + +export type WorkflowSessionFocusRoute = { + operationId: string; + destinationStepId: string; + entryIdentity: string; + destinationSessionId: string; + phase: "committed"; +}; + +/** + * The task projection returned by the move request. It is a candidate only; + * the live task projection remains authoritative when it is newer. + */ +export type WorkflowSessionFocusTaskProjection = { + metadata: unknown; + updatedAt?: string | null; + entryIdentity?: string | null; + workflowStepId?: string | null; +}; + +export type WorkflowSessionFocusReconcileInput = { + activeTaskId: string | null; + navigationRevision: number; + routeMetadata: unknown; + routeUpdatedAt?: string | null; + workflowStepId?: string | null; + responseProjection?: WorkflowSessionFocusTaskProjection; + knownSessionIds: readonly string[]; +}; + +export type WorkflowSessionFocusReconcileResult = { + state: WorkflowSessionFocusState; + sessionId: string | null; +}; + +export function createWorkflowSessionFocusState(): WorkflowSessionFocusState { + return { nextRequestId: 0, intent: null, request: null }; +} + +function clearWorkflowSessionFocusState( + state: WorkflowSessionFocusState, +): WorkflowSessionFocusState { + return { ...state, intent: null, request: null }; +} + +function nonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim(); + return normalized === "" ? null : normalized; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function timestampValue(value: string | null | undefined): bigint | null { + return parseTurnTimestamp(value ?? undefined); +} + +function acceptedTaskProjection( + intent: WorkflowSessionFocusIntent, + input: WorkflowSessionFocusReconcileInput, +): WorkflowSessionFocusTaskProjection { + const liveProjection: WorkflowSessionFocusTaskProjection = { + metadata: input.routeMetadata, + updatedAt: input.routeUpdatedAt, + workflowStepId: input.workflowStepId, + }; + const liveRoute = readCommittedWorkflowSessionRoute(input.routeMetadata); + if (liveRoute?.entryIdentity === intent.entryIdentity) return liveProjection; + + const response = input.responseProjection; + if (!response) return liveProjection; + + const responseRoute = readCommittedWorkflowSessionRoute(response.metadata); + const responseEntryIdentity = nonEmptyString(response.entryIdentity); + const liveUpdatedAt = timestampValue(input.routeUpdatedAt); + const responseUpdatedAt = timestampValue(response.updatedAt); + const responseIsEntryCorrelated = + responseEntryIdentity === intent.entryIdentity && + responseRoute?.entryIdentity === intent.entryIdentity; + const responseIsFreshEnough = + responseIsEntryCorrelated && + (liveUpdatedAt === null || (responseUpdatedAt !== null && responseUpdatedAt >= liveUpdatedAt)); + if (!responseIsFreshEnough) return liveProjection; + + // When timestamps tie, retain a different live committed entry. Equal + // timestamps are common for grouped task writes, and the live entry is + // the projection that won the store's ordering guard. + const liveHasDifferentCommittedEntry = + liveRoute !== null && liveRoute.entryIdentity !== intent.entryIdentity; + return liveHasDifferentCommittedEntry && responseUpdatedAt === liveUpdatedAt + ? liveProjection + : response; +} + +export function readCommittedWorkflowSessionRoute( + routeMetadata: unknown, +): WorkflowSessionFocusRoute | null { + const metadata = asRecord(routeMetadata); + const route = asRecord(metadata?.workflow_session_route ?? routeMetadata); + if (!route) return null; + + const operationId = nonEmptyString(route.operation_id); + const destinationStepId = nonEmptyString(route.destination_step_id); + const entryIdentity = nonEmptyString(route.entry_identity); + const destinationSessionId = nonEmptyString(route.destination_session_id); + if ( + !operationId || + !destinationStepId || + !entryIdentity || + !destinationSessionId || + route.phase !== "committed" + ) { + return null; + } + + return { + operationId, + destinationStepId, + entryIdentity, + destinationSessionId, + phase: "committed", + }; +} + +export function beginWorkflowSessionFocus( + state: WorkflowSessionFocusState, + input: WorkflowSessionFocusStart, +): { state: WorkflowSessionFocusState; requestId: number | null } { + if (!input.taskId || !input.workflowId || !input.destinationStepId) { + return { state, requestId: null }; + } + + const requestId = state.nextRequestId + 1; + return { + requestId, + state: { + nextRequestId: requestId, + intent: { ...input, requestId, entryIdentity: null }, + request: null, + }, + }; +} + +export function bindWorkflowSessionFocus( + state: WorkflowSessionFocusState, + input: { + requestId: number; + presentationToken: number; + entryIdentity: string; + }, +): WorkflowSessionFocusState { + const intent = state.intent; + if ( + !intent || + intent.requestId !== input.requestId || + intent.presentationToken !== input.presentationToken + ) { + return state; + } + + const entryIdentity = nonEmptyString(input.entryIdentity); + if (!entryIdentity) return clearWorkflowSessionFocusState(state); + + return { ...state, intent: { ...intent, entryIdentity } }; +} + +function scopeMatches( + value: WorkflowSessionFocusIntent | WorkflowSessionFocusRequest, + scope: WorkflowSessionFocusCancelScope, +): boolean { + if (scope.requestId !== undefined && value.requestId !== scope.requestId) return false; + if ( + scope.presentationToken !== undefined && + value.presentationToken !== scope.presentationToken + ) { + return false; + } + if (scope.taskId !== undefined && value.taskId !== scope.taskId) return false; + return true; +} + +export function cancelWorkflowSessionFocus( + state: WorkflowSessionFocusState, + scope: WorkflowSessionFocusCancelScope = {}, +): WorkflowSessionFocusState { + const activeValues = [state.intent, state.request].filter( + (value): value is WorkflowSessionFocusIntent | WorkflowSessionFocusRequest => value !== null, + ); + if (activeValues.length > 0 && !activeValues.some((value) => scopeMatches(value, scope))) { + return state; + } + return clearWorkflowSessionFocusState(state); +} + +export function reconcileWorkflowSessionFocus( + state: WorkflowSessionFocusState, + input: WorkflowSessionFocusReconcileInput, +): WorkflowSessionFocusReconcileResult { + const intent = state.intent; + if (!intent || !intent.entryIdentity) return { state, sessionId: null }; + + if ( + input.activeTaskId !== intent.taskId || + input.navigationRevision !== intent.navigationRevision + ) { + return { state: clearWorkflowSessionFocusState(state), sessionId: null }; + } + + const projection = acceptedTaskProjection(intent, input); + const route = readCommittedWorkflowSessionRoute(projection.metadata); + if ( + !route || + route.destinationStepId !== intent.destinationStepId || + route.entryIdentity !== intent.entryIdentity || + !input.knownSessionIds.includes(route.destinationSessionId) + ) { + return { state, sessionId: null }; + } + if (projection.workflowStepId !== intent.destinationStepId) { + return { state: clearWorkflowSessionFocusState(state), sessionId: null }; + } + + return { + state: { + ...state, + intent: null, + request: { + requestId: intent.requestId, + presentationToken: intent.presentationToken, + taskId: intent.taskId, + sessionId: route.destinationSessionId, + }, + }, + sessionId: route.destinationSessionId, + }; +} + +export function acknowledgeWorkflowSessionFocus( + state: WorkflowSessionFocusState, + requestId: number, +): WorkflowSessionFocusState { + if (state.request?.requestId !== requestId) return state; + return { ...state, request: null }; +} diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 7a3099f62a..f07ee1c5bf 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -567,6 +567,7 @@ export type WorkflowStepDTO = { export type MoveTaskResponse = { task: Task; workflow_step: WorkflowStepDTO; + workflow_entry_identity?: string; move_id?: string; entry_options?: { reset_context?: boolean; diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts index c179473ada..8f2e47fc6b 100644 --- a/apps/web/lib/ws/handlers/agent-session.ts +++ b/apps/web/lib/ws/handlers/agent-session.ts @@ -458,6 +458,7 @@ function syncEnvFromAgentctlPayload( ...getAgentctlWorktreeFields(payload, isSibling), workspace_path: payload.workspace_path ?? payload.task_workspace_path ?? payload.worktree_path, }); + store.getState().reconcileWorkflowSessionFocus?.(taskId); } /** Builds the partial-session patch applied for an agentctl_ready event. @@ -858,6 +859,7 @@ export function registerTaskSessionHandlers(store: StoreApi): WsHandle return { "message.queue.status_changed": (message) => handleQueueStatusChangedMessage(store, message.payload), + // eslint-disable-next-line complexity -- ordered session reconciliation keeps stale-event guards together "session.state_changed": (message) => { const payload = message.payload; if (!payload?.task_id) return; @@ -900,6 +902,7 @@ export function registerTaskSessionHandlers(store: StoreApi): WsHandle updatedAt: payload.updated_at, }); upsertTaskSessionList(store, taskId, sessionId, payload, sessionUpdate); + store.getState().reconcileWorkflowSessionFocus?.(taskId); syncKanbanPrimarySessionState(store, taskId, sessionId, newState); extractContextWindow(store, sessionId, payload); maybePromoteAgentctlReady(store, sessionId, newState, message.timestamp); diff --git a/apps/web/lib/ws/handlers/tasks.ts b/apps/web/lib/ws/handlers/tasks.ts index 116e2fa9e4..1dc0036c4f 100644 --- a/apps/web/lib/ws/handlers/tasks.ts +++ b/apps/web/lib/ws/handlers/tasks.ts @@ -368,6 +368,8 @@ function handleTaskUpdated(store: StoreApi, message: TaskUpdatedMessag }), ); + store.getState().reconcileWorkflowSessionFocus?.(taskId); + if (archivedAt) { if (!localRemovalOwnsDeparture) redirectAwayFromRemovedTask(taskId); return; @@ -403,6 +405,7 @@ export function registerTasksHandlers(store: StoreApi): WsHandlers { "task.deleted": (message) => { const deletedId = message.payload.task_id; const currentState = store.getState(); + currentState.cancelWorkflowSessionFocus?.({ taskId: deletedId }); removeRecentTask(deletedId); // A quick chat closed on another device must not linger here as a tab // pointing at a task the backend already deleted. diff --git a/docs/plans/workflow-session-focus/plan.md b/docs/plans/workflow-session-focus/plan.md new file mode 100644 index 0000000000..9339c4016c --- /dev/null +++ b/docs/plans/workflow-session-focus/plan.md @@ -0,0 +1,185 @@ +--- +created: 2026-09-19 +status: done +requirements: + - REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-003 +system_design: + - ../../specs/tasks/system-design/workflow-profile-session-lifecycle.md +legacy_specs: [] +--- + +# Implementation Plan: Workflow session focus + +## Overview + +After a manual step move, show the conversation selected for that entry. +One sequential work order covers entry correlation, local selection, and both +rendered surfaces. The implementation, review remediation, and permanent tests +are complete. + +The task system owns the contract because workflow routing selects the session. +Extend the existing lifecycle requirement/design pair rather than creating a +second UI contract. No architectural decision is needed for this local extension. + +## Scope + +In scope: the shared stepper on task detail and task preview, new and reused +recipients, pinned source tabs, delayed routing, stale responses, desktop and phone. + +Out of scope: changing agent routing, model selection, automatic transition +policy, background board moves, bulk moves, or session runtime recovery. + +## Evidence and confirmed decisions + +- The screenshot shows Implement with the earlier Astra conversation selected. +- `useWorkflowStepMove` awaits `moveTask` but discards its response. +- `shouldPreservePinnedSessionForTask` preserves nonterminal selected sessions. +- `shouldActivateSessionPanel` can preserve the previous panel for a new tab. +- The move response has `move_id`, but no committed step-entry identity. +- `WorkflowSessionRoute` already records entry identity and destination session. +- The linked hosted task was unavailable through the web tool. No live-instance + reproduction was performed; the source supports the identified failure paths. +- Profile-only workflow routing previously recorded only a source binding, so a + manual move could remain pinned to the source conversation. The remediation + now records the committed recipient route for profile changes, profile reuse, + same-profile keep-current, and replacement recovery. +- Delayed move responses previously supplied metadata without a freshness guard. + The store now reconciles one freshness-accepted task projection before it + considers the response projection. +- Dockview previously read the focus request only when session identity changed. + The tab hook now subscribes to the scoped request ID and acknowledges only + after activation. + +The user confirmed both choices during the interview: + +- Only manual step moves request this focus handoff. Automatic transitions keep + their existing focus behavior to avoid interrupting conversation reading. +- A later manual session selection cancels the pending handoff. If the user + selects Astra while Luna starts, Astra stays visible when Luna becomes ready. + +These decisions confirm the scope and acceptance criterion 003.3. +No material interview questions remain. Implementation is complete. + +## Technical approach + +Expose the committed entry identity from `MoveTaskWithOptions` through +`dto.MoveTaskResponse` and `lib/types/http.ts`. Keep it separate from `move_id`. +Capture it before the service refresh can replace the transition result. + +Use a shared ephemeral intent from `useWorkflowStepMove`. Reconcile the response +with committed route metadata and a task-owned session. Preserve request and +presentation guards. Cancel through explicit navigation revision changes. + +Update selection and clear its obsolete pin atomically. Consume the presentation +request after Dockview panel insertion or in the phone chat layout. Avoid the +panel restoration branch for this request. Do not change global WS adoption rules. + +Update `docs/public/tasks-and-workflows.md` with one short explanation of manual +move focus and later navigation. + +## ASCII UI preview + +UI-01: Task detail or preview, after moving from Plan to Implement. + +```text +Desktop before (reported) Desktop after +Implement 3/6 Implement 3/6 +[Astra selected] [Luna] [Plan] [Astra] [Luna selected] [Plan] +Earlier conversation Implement conversation and step prompt + +Phone after ++--------------------------------+ +| Task Implement 3/6 | +| Sessions: Luna v | +|--------------------------------| +| Implement conversation | +| Step prompt and agent output | +| (scrolls) | +|--------------------------------| +| Composer | +| Chat selected | Other panels | ++--------------------------------+ +``` + +Required structure: the recipient conversation becomes visible without another +tab click. Phone closes the step picker and shows chat. Names are illustrative. +No tab order, panel geometry, scroll ownership, or touch sizes change. +Do not open the phone keyboard. While routing is pending, retain the current +conversation and existing progress indicator. On failure, retain it and show +the existing error. A later manual selection cancels the handoff. + +## Tests + +All criteria belong to `AC-TASKS-WORKFLOW-PROFILE-SESSIONS-003`. + +| Criteria | Evidence | +| --- | --- | +| .1, .4 | Backend move-response tests preserve exact committed entry identity across refresh and omit it for no-op moves. | +| .1, .3, .4, .5 | Hook/store tests cover pin override, new/reused/same-session recipients, failures, queues, malformed routes, foreign rows, replay, and response/event order. | +| .3, .4 | Late response tests cover task departure/return, preview close/reopen, explicit tab choice, and overlapping moves including repeated destinations. | +| .2, .4 | Dockview and mobile component tests assert visible activation once, delayed panel insertion, and no extra launch or prompt. | + +Use `use-workflow-step-move.test.ts`, a new focused store helper test, +`dockview-session-tabs.hook.test.tsx`, and `session-mobile-layout.test.tsx`. +Retain existing WS pin-preservation tests as regression evidence. + +## E2E tests + +The added `workflow-session-focus.spec.ts` and +`mobile-workflow-session-focus.spec.ts` under `apps/web/e2e/tests/workflow/` +reuse the agent-switch fixture helpers. +Perform the move through the rendered stepper after explicitly selecting the +source conversation. API-only moves before page navigation cannot prove this fix. + +Cover new and reused recipients, a pinned source, and a later manual selection +while routing is delayed. Desktop proves task detail and preview focus. Phone +proves visible chat, picker selection, no keyboard autofocus, and no horizontal +overflow. Assert one workflow prompt, using causal HTTP/WS waits. + +## Work orders + +- [x] [Task 01: Focus the committed recipient](task-01-focus-recipient.md) (done) + +Review remediation is included in Task 01. No new work order is required. + +## Verification results + +Passed: + +- Focused backend Go tests in task service, handlers, DTO, and orchestrator. +- Focused frontend regression gate: 69 tests passed; the original six-file gate + also passed with 81 tests. +- Changed-file ESLint with `--max-warnings 0`: passed. +- Web typecheck and both managed E2E projects: desktop 4 passed, phone 1 passed. +- Public docs tests and validation, specification validation, sleep ratchet, and + `git diff --check`. + +## Review remediation results + +- [x] Profile-only steps publish an exact entry-correlated committed recipient + route across profile change, profile reuse, same-profile keep-current, and + terminalized-session replacement paths. Added profile-only pinned-source and + reuse/keep-current regression coverage. +- [x] Delayed HTTP response metadata is accepted only when its route, entry + identity, and freshness are compatible with the live task projection. Added + event-before-response and obsolete-response store/pure regressions. +- [x] Dockview subscribes to the task/session-scoped focus request ID and + acknowledges after activation. Added the mounted same-session, non-chat-panel + regression. + +Additional verification passed: + +```bash +(cd apps/backend && go test ./internal/orchestrator -count=1) +(cd apps/web && pnpm exec vitest run lib/state/workflow-session-focus.test.ts hooks/domains/kanban/use-workflow-step-move.test.ts components/task/dockview-session-tab-activation.test.ts components/task/dockview-session-tabs.test.ts components/task/dockview-session-tabs.hook.test.tsx --pool=forks --maxWorkers=1) +(cd apps/web && pnpm e2e:run --host --no-build tests/workflow/workflow-session-focus.spec.ts) +(cd apps/web && pnpm e2e:run --host --no-build --project mobile-chrome tests/workflow/mobile-workflow-session-focus.spec.ts) +``` + +## Risks + +- HTTP success can precede routing; reading response primary session alone is unsafe. +- Automatic Dockview selection must not look like a later manual selection. +- The linked hosted task was unavailable, so the local managed E2E scenarios are + the reproduction evidence for this revision. +- Background transitions retain their current behavior by design. diff --git a/docs/plans/workflow-session-focus/task-01-focus-recipient.md b/docs/plans/workflow-session-focus/task-01-focus-recipient.md new file mode 100644 index 0000000000..c1579ec92f --- /dev/null +++ b/docs/plans/workflow-session-focus/task-01-focus-recipient.md @@ -0,0 +1,157 @@ +--- +id: "01-focus-recipient" +title: "Focus the committed workflow recipient" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-003 +acceptance_criteria: + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-003.1 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-003.2 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-003.3 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-003.4 + - AC-TASKS-WORKFLOW-PROFILE-SESSIONS-003.5 +system_design: + - ../../specs/tasks/system-design/workflow-profile-session-lifecycle.md +--- + +# Task 01: Focus the committed workflow recipient + +## Summary + +Implement one manual-move focus handoff tied to the committed step entry. +Use TDD for entry correlation, selection races, and rendered activation. + +## In scope + +Own the move response identity, local intent, selection action, desktop/phone +consumers, targeted tests, and the public documentation described in the plan. +Preserve the existing background pin policy and session routing. + +## Out of scope + +Automatic transition policy, board navigation, agent lifecycle changes, and model selection. + +## Acceptance + +1. The matching committed recipient becomes the visible conversation on desktop + and phone, including new, reused, and already-selected sessions. +2. A later navigation or move cancels the pending handoff. Missing identity, + stale routes, no-op moves, errors, and unrelated sessions cannot steal focus. +3. All five criteria have passing evidence. Selection adds no launch or prompt. + +## ASCII UI preview + +UI-01, excerpt from the [full preview](plan.md#ascii-ui-preview), covers .1-.5. + +```text +Desktop: [Astra] [Luna selected] [Plan] -> Implement chat +Phone: [Sessions: Luna v] -> Chat selected +Pending/error: retain current conversation and existing status/error UI +``` + +Retain phone scroll ownership and safe areas. Close the step picker, but do +not focus the composer. Verify actual active panels, not only store values. + +## Verification + +Run from the repository root. In a fresh worktree, first install dependencies +with `(cd apps && pnpm install --frozen-lockfile)`. + +```bash +(cd apps/backend && go test ./internal/task/service ./internal/task/handlers ./internal/task/dto ./internal/orchestrator -run 'MoveTask|WorkflowSession|WorkflowFocus' -count=1) +(cd apps/web && pnpm exec vitest run hooks/domains/kanban/use-workflow-step-move.test.ts lib/state/workflow-session-focus.test.ts components/task/dockview-session-tabs.hook.test.tsx components/task/dockview-session-tab-activation.test.ts components/task/mobile/session-mobile-layout.test.tsx lib/ws/handlers/agent-session-pure.test.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm e2e:run --project chromium tests/workflow/workflow-session-focus.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/workflow/mobile-workflow-session-focus.spec.ts) +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +Name added backend tests with `WorkflowFocus` so the command includes them. +The new helper and E2E filenames above are owned deliverables. Run changed-file +ESLint as well. Record its exact file list and command with the results. +Managed E2E commands build fresh artifacts. Run the two projects sequentially. + +## Files likely touched + +- `apps/backend/internal/task/service/service_workflow.go` and focused tests. +- `apps/backend/internal/task/dto/dto.go` and move-response tests. +- `apps/backend/internal/task/handlers/task_http_handlers.go` and focused tests. +- `apps/backend/internal/orchestrator/workflow_session_target.go` and publication tests if required. +- `apps/web/lib/types/http.ts` and `lib/api/domains/kanban-api.ts`. +- `apps/web/hooks/domains/kanban/use-workflow-step-move.ts` and its tests. +- `apps/web/lib/state/workflow-session-focus.ts` and its new tests. +- `apps/web/lib/state/slices/kanban/{types,kanban-slice}.ts`. +- `apps/web/lib/ws/handlers/tasks.ts` for reconciliation if needed. +- `apps/web/components/task/dockview-session-tabs.ts` and activation tests. +- `apps/web/components/task/mobile/session-mobile-layout.tsx` and its tests. +- Both new E2E specs named in the verification block. +- `docs/public/tasks-and-workflows.md`. + +## Dependencies + +None. Use existing route metadata, transition identity, and manual navigation revision. + +## Risks + +Do not conflate `move_id`, entry identity, and route operation identity. +Do not let a refreshed task replace the response's committed entry identity. +Reconcile both response-first and event-first sequences. Keep cancelled intents +cancelled across reconnect, remount, and later repeated destinations. + +## Parallelism + +`sequential` + +## Inputs + +- Requirement 003 and the design's Manual move recipient focus section. +- The plan's evidence, scenario matrix, and UI-01 preview. +- Existing workflow agent-switch fixtures and Dockview activation tests. +- Scoped backend/web guidance; TDD, E2E, and mobile-parity skills. + +## Results + +Implementation is complete. The move response carries the committed entry +identity, shared browser-local intent reconciles response-first and event-first +updates, and desktop plus phone consume the same recipient focus request. + +The exact verification commands passed: + +```bash +(cd apps/backend && go test ./internal/task/service ./internal/task/handlers ./internal/task/dto ./internal/orchestrator -run 'MoveTask|WorkflowSession|WorkflowFocus' -count=1) +(cd apps/web && pnpm exec vitest run hooks/domains/kanban/use-workflow-step-move.test.ts lib/state/workflow-session-focus.test.ts components/task/dockview-session-tabs.hook.test.tsx components/task/dockview-session-tab-activation.test.ts components/task/mobile/session-mobile-layout.test.tsx lib/ws/handlers/agent-session-pure.test.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm e2e:run --project chromium tests/workflow/workflow-session-focus.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/workflow/mobile-workflow-session-focus.spec.ts) +``` + +The desktop E2E passed 4 tests and the phone E2E passed 1 test. The focused +Vitest gate passed 69 tests; the original six-file gate passed 81 tests. The +changed-file lint command also passed: + +```bash +(cd apps/web && pnpm exec eslint --max-warnings 0 components/kanban-with-preview.tsx components/task/dockview-session-tab-activation.test.ts components/task/dockview-session-tab-activation.ts components/task/dockview-session-tabs.ts components/task/mobile/mobile-task-move-options.tsx components/task/mobile/session-mobile-layout.tsx components/task/mobile/session-task-switcher-sheet-props.ts components/task/mobile/session-task-switcher-sheet.tsx components/task/task-move-context-menu.tsx hooks/domains/kanban/use-workflow-step-move.test.ts hooks/domains/kanban/use-workflow-step-move.ts hooks/use-task-sessions.ts lib/state/default-state.ts lib/state/slices/kanban/kanban-slice.ts lib/state/slices/kanban/types.ts lib/state/store-overrides.ts lib/types/http.ts lib/ws/handlers/agent-session.ts lib/ws/handlers/tasks.ts lib/state/workflow-session-focus.ts lib/state/workflow-session-focus.test.ts) +``` + +Public docs validation, specification validation, the E2E sleep ratchet, and +`git diff --check` passed. + +Review remediation is complete. Profile-only workflow steps now publish an +entry-correlated committed recipient route for profile changes, reuse, +same-profile keep-current, and terminalized-session replacement. Delayed HTTP +move metadata is accepted only after route, entry, and freshness checks against +the live task projection. Dockview subscribes to the scoped focus request ID +and acknowledges it only after activating the requested session panel. + +The remediation regressions passed for profile-only pinned-source routing, +profile reuse and keep-current routing, event-before-response and obsolete HTTP +metadata, and same-session Dockview activation from a non-chat panel. The full +orchestrator package also passed after the compatibility and replacement-route +fixes. diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index 6b3e117e5a..ce2f1eb28b 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -67,6 +67,12 @@ The preview is advisory. **Move here** checks routing, permissions, WIP, and current session state again when the move runs. A preview can therefore change while a turn is running or while another session becomes available. +After you move an open task with **Move here**, Kandev follows the conversation +committed for the destination step on desktop and phone. It waits for that +conversation to become available, so a pending or failed move keeps the +conversation you selected. If you select another conversation while routing is +in progress, that selection stays active. + ## Move a task with one-time entry options The normal **Move here** and next-step actions use the destination step's saved workflow defaults. When one transition needs an exception, open **Move with options** from the workflow stepper, Chat status bar, or passthrough toolbar. The options apply only to that entry and never rewrite the workflow step. diff --git a/docs/specs/tasks/requirements/workflow-profile-session-lifecycle.md b/docs/specs/tasks/requirements/workflow-profile-session-lifecycle.md index 380cbc6e55..4b387f2e01 100644 --- a/docs/specs/tasks/requirements/workflow-profile-session-lifecycle.md +++ b/docs/specs/tasks/requirements/workflow-profile-session-lifecycle.md @@ -172,6 +172,22 @@ requiring its profile to be known when the workflow is authored. prevent combining it with an explicit session target. Choosing a target shall not silently discard existing conditional rules. +### REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-003: Focus the recipient after a manual step move + +**Intent:** Show the conversation selected by the step that the user enters. + +#### Acceptance criteria + +- **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-003.1:** After a user moves the open task to a step, Kandev shall select that entry's committed recipient. This applies to new and reused conversations, including a destination with the same profile. A session pin established before the move shall not prevent this selection. +- **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-003.2:** Desktop shall activate the recipient's conversation tab. Phone shall show its chat and selected session in the session picker. Selecting the session shall not send another prompt or start another execution. +- **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-003.3:** A later manual session selection, task departure, closed preview, or newer move shall cancel the earlier pending focus. Returning to the same task shall not restore that intent. Background transitions shall retain their existing selection policy. +- **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-003.4:** Delayed, repeated, and reordered updates shall focus the matching recipient at most once. An unrelated session, stale entry, foreign session, or prepared route shall not qualify. Missing or ambiguous identity shall preserve the current view. +- **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-003.5:** A failed move shall preserve the selected conversation and show the existing error. A queued entry without a committed recipient shall preserve selection until its recipient becomes available, subject to criterion 003.3. A step with no recipient shall not create one for navigation. + +## Implementation plans + +- [Manual workflow session focus](../../../plans/workflow-session-focus/plan.md) + ## Example A task starts with Sol, which the user selects at task creation. Plan parks diff --git a/docs/specs/tasks/system-design/workflow-profile-session-lifecycle.md b/docs/specs/tasks/system-design/workflow-profile-session-lifecycle.md index aca4623631..3a7969b0bc 100644 --- a/docs/specs/tasks/system-design/workflow-profile-session-lifecycle.md +++ b/docs/specs/tasks/system-design/workflow-profile-session-lifecycle.md @@ -4,6 +4,7 @@ system: tasks requirements: - REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-001 - REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-002 + - REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-003 --- # Workflow Step Profile Session Lifecycle System Design @@ -32,6 +33,7 @@ session's model settings without switching profiles. | --- | --- | | `REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-001` | [Data and contracts](#data-and-contracts), [Control flow](#control-flow), [Failure and recovery](#failure-and-recovery), [Combined step agent selector](#combined-step-agent-selector) | | `REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-002` | [Explicit recipient contract](#explicit-recipient-contract), [Recipient bindings](#recipient-bindings), [Explicit routing flow](#explicit-routing-flow), [Combined step agent selector](#combined-step-agent-selector) | +| `REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-003` | [Manual move recipient focus](#manual-move-recipient-focus) | ## Components and responsibilities @@ -497,6 +499,70 @@ logs include session ID, execution ID, and intent stamp. - [Explicit session targeting](../../../plans/workflow-session-targeting/plan.md) - [Same-profile fresh-session repair](../../../plans/workflow-same-profile-new-session/plan.md) +## Manual move recipient focus + +Requirement `REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-003` maps to this section. +This extension was implemented on 2026-09-19. Tasks owns it because workflow +entry routing determines the recipient. UI owns its existing panel primitives. + +### Entry correlation + +`useWorkflowStepMove` currently discards the move response. Background handlers +preserve nonterminal pins, and Dockview can preserve another panel on insertion. +Neither mechanism expresses the user's manual move intent. + +Add optional `workflow_entry_identity` to the HTTP move response and its web +types. Capture the committed transition identifier in `MoveTaskWithOptions` +before its task refresh. Format it with the existing workflow entry identity +convention. Do not derive it from the refreshed task or treat `move_id` as the +route operation ID. A no-op move omits the identity. + +Use `workflow_session_route.entry_identity`, `destination_step_id`, +`destination_session_id`, and `phase` from the accepted task projection. +Require the response identity, requested step, committed phase, and task-owned +session to match. Carry committed route metadata through the existing +`task.updated` publication after promotion if that publication omits it. +Preserve existing stale-projection guards. Do not predict the recipient from +preview data, session creation order, primary promotion alone, or profile name. + +### Local intent and presentation + +Add one ephemeral focus intent per active task presentation, owned by shared +store actions. Record request sequence, presentation token, task, workflow, +destination step, and manual navigation revision when the move starts. +Bind its entry identity only after the matching response succeeds. Reconcile +against already-received state and subsequent updates, in either order. + +Cancel on error, unmount, presentation change, task departure, later explicit +session selection, or replacement by a newer request. Selection away and back +still cancels. Reuse the manual navigation revision that `setActiveSession` +already advances; do not infer user action from automatic Dockview callbacks. + +After the destination row is available, clear the earlier pin and update active +session plus remembered selection atomically. Retain a one-shot presentation +request until the target panel exists. Dockview consumes it after session-panel +creation and bypasses preserved-panel restoration for this request only. +Phone consumes the same request to select chat and close the step picker. +Acknowledgement clears it. Never reuse the short tab-pointer intent TTL for an +asynchronous workflow entry. Session selection cannot call launch or send APIs. + +### Mobile, recovery, and compatibility + +Reuse `session-mobile-layout.tsx` and `MobileSessionsPicker`: direct chat is the +primary content, and the inset picker remains a temporary session choice. +Keep the existing transcript scroll owner, dynamic viewport, safe-area spacing, +and touch geometry. Do not autofocus the composer or open the phone keyboard. + +No database migration, routing change, or new runtime flag is required. Intent +is browser-local and disappears on reload. Old servers without entry identity +retain existing selection behavior. Reconnect reconciliation uses accepted task +and session projections and cannot revive cancelled intent. Missing, malformed, +foreign, or ambiguous targets preserve selection. Existing errors remain visible. + +See the [implementation plan](../../../plans/workflow-session-focus/plan.md) +for the desktop/phone preview and race coverage. Existing routing diagnostics +remain sufficient; no new metric or prompt logging is required. + ## Related decisions - [Make workflow step profile-session switching explicit](../../../decisions/2026-08-31-workflow-profile-session-switch-policy.md)